3d
This commit is contained in:
@@ -1,535 +0,0 @@
|
||||
// 2026-04-24 | test_body_analysis.dart | 身体分析算法验证脚本 | 验证所有计算公式正确性
|
||||
// 运行: dart run scripts/test_body_analysis.dart
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
|
||||
// ===== 复制核心计算逻辑(独立验证,不依赖Flutter) =====
|
||||
|
||||
enum Gender { male, female }
|
||||
|
||||
enum ActivityLevel { sedentary, light, moderate, active, veryActive }
|
||||
|
||||
class BodyAnalysisCalculator {
|
||||
final Gender gender;
|
||||
final double age;
|
||||
final double height;
|
||||
final double weight;
|
||||
final double? waist;
|
||||
final double? hip;
|
||||
final double? neck;
|
||||
final double? wrist;
|
||||
final ActivityLevel activityLevel;
|
||||
final double? bodyFatInput;
|
||||
|
||||
BodyAnalysisCalculator({
|
||||
required this.gender,
|
||||
required this.age,
|
||||
required this.height,
|
||||
required this.weight,
|
||||
this.waist,
|
||||
this.hip,
|
||||
this.neck,
|
||||
this.wrist,
|
||||
this.activityLevel = ActivityLevel.moderate,
|
||||
this.bodyFatInput,
|
||||
});
|
||||
|
||||
bool get isValid => age > 0 && height > 0 && weight > 0;
|
||||
|
||||
double get bmi {
|
||||
if (!isValid) return 0;
|
||||
final h = height / 100;
|
||||
return weight / (h * h);
|
||||
}
|
||||
|
||||
String get bmiCategory {
|
||||
if (bmi < 18.5) return '偏瘦';
|
||||
if (bmi < 24) return '正常';
|
||||
if (bmi < 28) return '偏胖';
|
||||
return '肥胖';
|
||||
}
|
||||
|
||||
double get bmr {
|
||||
if (!isValid) return 0;
|
||||
if (gender == Gender.male) {
|
||||
return 10 * weight + 6.25 * height - 5 * age + 5;
|
||||
}
|
||||
return 10 * weight + 6.25 * height - 5 * age - 161;
|
||||
}
|
||||
|
||||
double get tdee {
|
||||
final multipliers = {
|
||||
ActivityLevel.sedentary: 1.2,
|
||||
ActivityLevel.light: 1.375,
|
||||
ActivityLevel.moderate: 1.55,
|
||||
ActivityLevel.active: 1.725,
|
||||
ActivityLevel.veryActive: 1.9,
|
||||
};
|
||||
return bmr * (multipliers[activityLevel] ?? 1.55);
|
||||
}
|
||||
|
||||
double get bodyFatRate {
|
||||
if (bodyFatInput != null && bodyFatInput! > 0 && bodyFatInput! < 80) {
|
||||
return bodyFatInput!;
|
||||
}
|
||||
if (neck != null && neck! > 0 && waist != null && waist! > 0) {
|
||||
return navyBodyFat;
|
||||
}
|
||||
return bmiBodyFat;
|
||||
}
|
||||
|
||||
double get bmiBodyFat {
|
||||
if (bmi <= 0) return 0;
|
||||
if (gender == Gender.male) {
|
||||
return (1.20 * bmi) + (0.23 * age) - 16.2;
|
||||
}
|
||||
return (1.20 * bmi) + (0.23 * age) - 5.4;
|
||||
}
|
||||
|
||||
double get navyBodyFat {
|
||||
if (waist == null || waist! <= 0 || neck == null || neck! <= 0) return 0;
|
||||
if (gender == Gender.male) {
|
||||
final logWaistNeck = _log10(waist! - neck!);
|
||||
final logHeight = _log10(height);
|
||||
return 495 / (1.0324 - 0.19077 * logWaistNeck + 0.15456 * logHeight) -
|
||||
450;
|
||||
}
|
||||
if (hip == null || hip! <= 0) return 0;
|
||||
final logWaistHipNeck = _log10(waist! + hip! - neck!);
|
||||
final logHeight = _log10(height);
|
||||
return 495 / (1.29579 - 0.35004 * logWaistHipNeck + 0.22100 * logHeight) -
|
||||
450;
|
||||
}
|
||||
|
||||
double get dailyWater => isValid ? weight * 35 : 0;
|
||||
|
||||
double get leanBodyMass => isValid ? weight * (1 - bodyFatRate / 100) : 0;
|
||||
|
||||
double get boneWeight => isValid ? weight * 0.15 : 0;
|
||||
|
||||
double get bloodVolume {
|
||||
if (!isValid) return 0;
|
||||
return gender == Gender.male ? weight * 75 : weight * 65;
|
||||
}
|
||||
|
||||
double get bodySurfaceArea {
|
||||
if (!isValid) return 0;
|
||||
return 0.007184 * math.pow(height, 0.725) * math.pow(weight, 0.425);
|
||||
}
|
||||
|
||||
double get idealWeightDevine {
|
||||
if (!isValid) return 0;
|
||||
final base = gender == Gender.male ? 50.0 : 45.5;
|
||||
return base + 2.3 * ((height / 2.54) - 60);
|
||||
}
|
||||
|
||||
double get idealWeightRobinson {
|
||||
if (!isValid) return 0;
|
||||
final base = gender == Gender.male ? 52.0 : 49.0;
|
||||
final factor = gender == Gender.male ? 1.9 : 1.7;
|
||||
return base + factor * ((height / 2.54) - 60);
|
||||
}
|
||||
|
||||
double get idealWeightMiller {
|
||||
if (!isValid) return 0;
|
||||
final base = gender == Gender.male ? 56.2 : 53.1;
|
||||
final factor = gender == Gender.male ? 1.41 : 1.36;
|
||||
return base + factor * ((height / 2.54) - 60);
|
||||
}
|
||||
|
||||
double get idealWeightHamwi {
|
||||
if (!isValid) return 0;
|
||||
final base = gender == Gender.male ? 48.0 : 45.5;
|
||||
final factor = gender == Gender.male ? 2.7 : 2.2;
|
||||
return base + factor * ((height / 2.54) - 60);
|
||||
}
|
||||
|
||||
int get metabolicAge {
|
||||
if (!isValid) return 0;
|
||||
final avgBmr = gender == Gender.male
|
||||
? 10 * 70 + 6.25 * 170 - 5 * age + 5
|
||||
: 10 * 60 + 6.25 * 160 - 5 * age - 161;
|
||||
final ratio = bmr / avgBmr;
|
||||
return (age * ratio).round().clamp(1, 120);
|
||||
}
|
||||
|
||||
double get waistToHeightRatio {
|
||||
if (waist == null || waist! <= 0) return -1;
|
||||
return waist! / height;
|
||||
}
|
||||
|
||||
double get waistToHipRatio {
|
||||
if (waist == null || waist! <= 0 || hip == null || hip! <= 0) return -1;
|
||||
return waist! / hip!;
|
||||
}
|
||||
|
||||
double get ffmi {
|
||||
if (!isValid) return 0;
|
||||
final heightM = height / 100;
|
||||
return leanBodyMass / (heightM * heightM);
|
||||
}
|
||||
|
||||
double get normalizedFfmi {
|
||||
if (!isValid) return 0;
|
||||
final heightM = height / 100;
|
||||
return ffmi + 6.1 * (1.8 - heightM);
|
||||
}
|
||||
|
||||
double get ponderalIndex {
|
||||
if (!isValid) return 0;
|
||||
final heightM = height / 100;
|
||||
return weight / (heightM * heightM * heightM);
|
||||
}
|
||||
|
||||
double get bmrPerKg => isValid ? bmr / weight : 0;
|
||||
|
||||
double get conicityIndex {
|
||||
if (!isValid || waist == null || waist! <= 0) return -1;
|
||||
final waistM = waist! / 100;
|
||||
final heightM = height / 100;
|
||||
return waistM / (0.109 * math.sqrt(weight / heightM));
|
||||
}
|
||||
|
||||
int get maxHeartRate => (220 - age).round();
|
||||
|
||||
int get dailyHeartbeats {
|
||||
if (age < 1) return 130 * 60 * 24;
|
||||
if (age < 10) return 90 * 60 * 24;
|
||||
if (age < 20) return 75 * 60 * 24;
|
||||
if (age < 40) return 72 * 60 * 24;
|
||||
return 70 * 60 * 24;
|
||||
}
|
||||
|
||||
int get dailyBreaths => 16 * 60 * 24;
|
||||
|
||||
int get dailyBlinks => 14400;
|
||||
|
||||
double get fatLossCalories => tdee - 500;
|
||||
|
||||
double get muscleGainCalories => tdee + 300;
|
||||
|
||||
double get maintenanceCalories => tdee;
|
||||
|
||||
double get proteinForFatLoss => weight * 2.0;
|
||||
|
||||
double get proteinForMuscleGain => weight * 2.2;
|
||||
|
||||
double get proteinForMaintenance => weight * 1.2;
|
||||
|
||||
double _log10(double x) {
|
||||
if (x <= 0) return 0;
|
||||
return math.log(x) / math.ln10;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 测试框架 =====
|
||||
|
||||
int _passCount = 0;
|
||||
int _failCount = 0;
|
||||
final List<String> _failMessages = [];
|
||||
|
||||
void expect(
|
||||
String name,
|
||||
double actual,
|
||||
double expected, {
|
||||
double tolerance = 0.5,
|
||||
}) {
|
||||
final diff = (actual - expected).abs();
|
||||
if (diff <= tolerance) {
|
||||
_passCount++;
|
||||
print(' ✅ $name: $actual (期望 $expected, 误差 ${diff.toStringAsFixed(2)})');
|
||||
} else {
|
||||
_failCount++;
|
||||
final msg =
|
||||
' ❌ $name: $actual (期望 $expected, 误差 ${diff.toStringAsFixed(2)})';
|
||||
_failMessages.add(msg);
|
||||
print(msg);
|
||||
}
|
||||
}
|
||||
|
||||
void expectRange(String name, double actual, double min, double max) {
|
||||
if (actual >= min && actual <= max) {
|
||||
_passCount++;
|
||||
print(' ✅ $name: $actual (范围 $min-$max)');
|
||||
} else {
|
||||
_failCount++;
|
||||
final msg = ' ❌ $name: $actual (超出范围 $min-$max)';
|
||||
_failMessages.add(msg);
|
||||
print(msg);
|
||||
}
|
||||
}
|
||||
|
||||
void expectPositive(String name, double actual) {
|
||||
if (actual > 0) {
|
||||
_passCount++;
|
||||
print(' ✅ $name: $actual (> 0)');
|
||||
} else {
|
||||
_failCount++;
|
||||
final msg = ' ❌ $name: $actual (应 > 0)';
|
||||
_failMessages.add(msg);
|
||||
print(msg);
|
||||
}
|
||||
}
|
||||
|
||||
void section(String title) {
|
||||
print('\n📋 $title');
|
||||
print('${'─' * 50}');
|
||||
}
|
||||
|
||||
// ===== 测试用例 =====
|
||||
|
||||
void main() {
|
||||
print('🧬 身体分析算法验证脚本');
|
||||
print('📅 2026-04-24');
|
||||
print('${'═' * 50}');
|
||||
|
||||
// 标准测试数据:30岁男性,175cm,75kg
|
||||
final male30 = BodyAnalysisCalculator(
|
||||
gender: Gender.male,
|
||||
age: 30,
|
||||
height: 175,
|
||||
weight: 75,
|
||||
activityLevel: ActivityLevel.moderate,
|
||||
);
|
||||
|
||||
// 标准测试数据:25岁女性,165cm,55kg
|
||||
final female25 = BodyAnalysisCalculator(
|
||||
gender: Gender.female,
|
||||
age: 25,
|
||||
height: 165,
|
||||
weight: 55,
|
||||
activityLevel: ActivityLevel.moderate,
|
||||
);
|
||||
|
||||
// 带选填参数的男性
|
||||
final maleWithOptional = BodyAnalysisCalculator(
|
||||
gender: Gender.male,
|
||||
age: 30,
|
||||
height: 175,
|
||||
weight: 75,
|
||||
waist: 85,
|
||||
hip: 95,
|
||||
neck: 38,
|
||||
wrist: 17,
|
||||
activityLevel: ActivityLevel.moderate,
|
||||
);
|
||||
|
||||
// 带选填参数的女性
|
||||
final femaleWithOptional = BodyAnalysisCalculator(
|
||||
gender: Gender.female,
|
||||
age: 25,
|
||||
height: 165,
|
||||
weight: 55,
|
||||
waist: 70,
|
||||
hip: 95,
|
||||
neck: 34,
|
||||
wrist: 15,
|
||||
activityLevel: ActivityLevel.light,
|
||||
);
|
||||
|
||||
// ===== 1. BMI 测试 =====
|
||||
section('1. BMI 身体质量指数');
|
||||
expect('男性BMI', male30.bmi, 24.5, tolerance: 0.1);
|
||||
expect('女性BMI', female25.bmi, 20.2, tolerance: 0.1);
|
||||
expectRange('男性BMI范围', male30.bmi, 18, 30);
|
||||
expectRange('女性BMI范围', female25.bmi, 18, 25);
|
||||
|
||||
// ===== 2. BMR 测试 =====
|
||||
section('2. BMR 基础代谢率 (Mifflin-St Jeor)');
|
||||
// 男性: 10*75 + 6.25*175 - 5*30 + 5 = 750 + 1093.75 - 150 + 5 = 1698.75
|
||||
expect('男性BMR', male30.bmr, 1698.75, tolerance: 0.5);
|
||||
// 女性: 10*55 + 6.25*165 - 5*25 - 161 = 550 + 1031.25 - 125 - 161 = 1295.25
|
||||
expect('女性BMR', female25.bmr, 1295.25, tolerance: 0.5);
|
||||
|
||||
// ===== 3. TDEE 测试 =====
|
||||
section('3. TDEE 每日总能量消耗');
|
||||
// 男性中度: 1698.75 * 1.55 = 2633.06
|
||||
expect('男性TDEE(中度)', male30.tdee, 2633.06, tolerance: 1.0);
|
||||
// 女性轻度: 1295.25 * 1.375 = 1780.97
|
||||
expect('女性TDEE(轻度)', femaleWithOptional.tdee, 1780.97, tolerance: 1.0);
|
||||
|
||||
// ===== 4. 体脂率测试 =====
|
||||
section('4. 体脂率 (BMI估算法)');
|
||||
// 男性: 1.20*24.5 + 0.23*30 - 16.2 = 29.4 + 6.9 - 16.2 = 20.1
|
||||
expect('男性体脂率(BMI)', male30.bodyFatRate, 20.1, tolerance: 0.5);
|
||||
// 女性: 1.20*20.2 + 0.23*25 - 5.4 = 24.24 + 5.75 - 5.4 = 24.59
|
||||
expect('女性体脂率(BMI)', female25.bodyFatRate, 24.59, tolerance: 0.5);
|
||||
|
||||
// ===== 5. Navy体脂率测试 =====
|
||||
section('5. 体脂率 (Navy法)');
|
||||
// 男性Navy: 495/(1.0324 - 0.19077*log10(85-38) + 0.15456*log10(175)) - 450
|
||||
expectRange('男性Navy体脂率', maleWithOptional.bodyFatRate, 10, 30);
|
||||
expectRange('女性Navy体脂率', femaleWithOptional.bodyFatRate, 15, 35);
|
||||
|
||||
// ===== 6. 每日喝水量 =====
|
||||
section('6. 每日喝水量');
|
||||
expect('男性喝水量', male30.dailyWater, 75 * 35, tolerance: 1);
|
||||
expect('女性喝水量', female25.dailyWater, 55 * 35, tolerance: 1);
|
||||
|
||||
// ===== 7. 人体表面积 (Mosteller) =====
|
||||
section('7. 人体表面积 (Mosteller)');
|
||||
// BSA = sqrt(175 * 75 / 3600) = sqrt(3.6458) = 1.909
|
||||
expect('男性BSA', male30.bodySurfaceArea, 1.909, tolerance: 0.05);
|
||||
// BSA = sqrt(165 * 55 / 3600) = sqrt(2.5208) = 1.588
|
||||
expect('女性BSA', female25.bodySurfaceArea, 1.588, tolerance: 0.05);
|
||||
|
||||
// ===== 8. 血液总量 =====
|
||||
section('8. 血液总量');
|
||||
expect('男性血液', male30.bloodVolume, 75 * 75, tolerance: 1);
|
||||
expect('女性血液', female25.bloodVolume, 55 * 65, tolerance: 1);
|
||||
|
||||
// ===== 9. 骨骼重量 =====
|
||||
section('9. 骨骼重量');
|
||||
expect('男性骨骼', male30.boneWeight, 75 * 0.15, tolerance: 0.5);
|
||||
expect('女性骨骼', female25.boneWeight, 55 * 0.15, tolerance: 0.5);
|
||||
|
||||
// ===== 10. 去脂体重 =====
|
||||
section('10. 去脂体重 (LBM)');
|
||||
expectRange('男性LBM', male30.leanBodyMass, 50, 70);
|
||||
expectRange('女性LBM', female25.leanBodyMass, 35, 50);
|
||||
|
||||
// ===== 11. 理想体重 =====
|
||||
section('11. 理想体重 (4种公式)');
|
||||
// Devine: 50 + 2.3*((175/2.54)-60) = 50 + 2.3*8.8976 = 50 + 20.46 = 70.46
|
||||
expect('男性Devine', male30.idealWeightDevine, 70.46, tolerance: 0.5);
|
||||
expectRange('男性Robinson', male30.idealWeightRobinson, 60, 80);
|
||||
expectRange('男性Miller', male30.idealWeightMiller, 60, 80);
|
||||
expectRange('男性Hamwi', male30.idealWeightHamwi, 60, 80);
|
||||
expectRange('女性Devine', female25.idealWeightDevine, 45, 65);
|
||||
expectRange('女性Robinson', female25.idealWeightRobinson, 45, 65);
|
||||
|
||||
// ===== 12. 代谢年龄 =====
|
||||
section('12. 代谢年龄');
|
||||
expectRange('男性代谢年龄', male30.metabolicAge.toDouble(), 20, 40);
|
||||
expectRange('女性代谢年龄', female25.metabolicAge.toDouble(), 18, 35);
|
||||
|
||||
// ===== 13. 腰围身高比 =====
|
||||
section('13. 腰围身高比 (WHtR)');
|
||||
expect(
|
||||
'男性WHtR',
|
||||
maleWithOptional.waistToHeightRatio,
|
||||
85 / 175,
|
||||
tolerance: 0.01,
|
||||
);
|
||||
expect(
|
||||
'女性WHtR',
|
||||
femaleWithOptional.waistToHeightRatio,
|
||||
70 / 165,
|
||||
tolerance: 0.01,
|
||||
);
|
||||
|
||||
// ===== 14. 腰臀比 =====
|
||||
section('14. 腰臀比 (WHR)');
|
||||
expect('男性WHR', maleWithOptional.waistToHipRatio, 85 / 95, tolerance: 0.01);
|
||||
expect('女性WHR', femaleWithOptional.waistToHipRatio, 70 / 95, tolerance: 0.01);
|
||||
|
||||
// ===== 15. FFMI =====
|
||||
section('15. 无脂肪质量指数 (FFMI)');
|
||||
expectRange('男性FFMI', male30.ffmi, 15, 25);
|
||||
expectRange('女性FFMI', female25.ffmi, 12, 22);
|
||||
expectRange('男性标准化FFMI', male30.normalizedFfmi, 15, 25);
|
||||
|
||||
// ===== 16. Ponderal Index =====
|
||||
section('16. Ponderal 体重指数');
|
||||
// PI = 75 / (1.75^3) = 75 / 5.359 = 13.99
|
||||
expect('男性PI', male30.ponderalIndex, 14.0, tolerance: 0.5);
|
||||
// PI = 55 / (1.65^3) = 55 / 4.492 = 12.24
|
||||
expect('女性PI', female25.ponderalIndex, 12.24, tolerance: 0.5);
|
||||
|
||||
// ===== 17. BMR效率 =====
|
||||
section('17. 基础代谢效率 (BMR/kg)');
|
||||
expect('男性BMR/kg', male30.bmrPerKg, 1698.75 / 75, tolerance: 0.5);
|
||||
expect('女性BMR/kg', female25.bmrPerKg, 1295.25 / 55, tolerance: 0.5);
|
||||
|
||||
// ===== 18. 锥度指数 =====
|
||||
section('18. 锥度指数 (Conicity Index)');
|
||||
// CI = 85 / (0.109 * sqrt(75/175)) = 85 / (0.109 * 0.6547) = 85 / 0.07136 = 1191.2
|
||||
// Wait, that seems wrong. Let me recalculate:
|
||||
// CI = waist / (0.109 * sqrt(weight/height)) where waist is in meters? No, cm.
|
||||
// Actually the formula uses waist in meters: 0.85 / (0.109 * sqrt(75/1.75)) = 0.85 / (0.109 * 6.547) = 0.85 / 0.7136 = 1.191
|
||||
// But our code uses waist in cm: 85 / (0.109 * sqrt(75/175)) = 85 / (0.109 * 0.6547) = 85 / 0.07136 = 1191.2
|
||||
// This looks like a bug! The formula should use waist in meters.
|
||||
expectRange('男性CI', maleWithOptional.conicityIndex, 1.0, 1.5);
|
||||
|
||||
// ===== 19. 最大心率 =====
|
||||
section('19. 最大心率');
|
||||
expect('男性最大心率', male30.maxHeartRate.toDouble(), 190, tolerance: 1);
|
||||
expect('女性最大心率', female25.maxHeartRate.toDouble(), 195, tolerance: 1);
|
||||
|
||||
// ===== 20. 每日心跳/呼吸/眨眼 =====
|
||||
section('20. 每日心跳/呼吸/眨眼');
|
||||
expectPositive('男性每日心跳', male30.dailyHeartbeats.toDouble());
|
||||
expectPositive('男性每日呼吸', male30.dailyBreaths.toDouble());
|
||||
expectPositive('男性每日眨眼', male30.dailyBlinks.toDouble());
|
||||
|
||||
// ===== 21. 摄入需求 =====
|
||||
section('21. 减脂/增肌/维持摄入需求');
|
||||
expect('男性减脂', male30.fatLossCalories, male30.tdee - 500, tolerance: 1);
|
||||
expect('男性增肌', male30.muscleGainCalories, male30.tdee + 300, tolerance: 1);
|
||||
expect('男性维持', male30.maintenanceCalories, male30.tdee, tolerance: 1);
|
||||
|
||||
// ===== 22. 蛋白质需求 =====
|
||||
section('22. 蛋白质需求量');
|
||||
expect('男性减脂蛋白', male30.proteinForFatLoss, 75 * 2.0, tolerance: 1);
|
||||
expect('男性增肌蛋白', male30.proteinForMuscleGain, 75 * 2.2, tolerance: 1);
|
||||
expect('男性维持蛋白', male30.proteinForMaintenance, 75 * 1.2, tolerance: 1);
|
||||
|
||||
// ===== 23. 边界值测试 =====
|
||||
section('23. 边界值测试');
|
||||
final invalid = BodyAnalysisCalculator(
|
||||
gender: Gender.male,
|
||||
age: 0,
|
||||
height: 0,
|
||||
weight: 0,
|
||||
);
|
||||
expect('无效输入BMI', invalid.bmi, 0, tolerance: 0.01);
|
||||
expect('无效输入BMR', invalid.bmr, 0, tolerance: 0.01);
|
||||
expect('无效输入TDEE', invalid.tdee, 0, tolerance: 0.01);
|
||||
|
||||
final extreme = BodyAnalysisCalculator(
|
||||
gender: Gender.male,
|
||||
age: 80,
|
||||
height: 200,
|
||||
weight: 120,
|
||||
);
|
||||
expectRange('极端BMI', extreme.bmi, 20, 40);
|
||||
expectPositive('极端BMR', extreme.bmr);
|
||||
expectPositive('极端TDEE', extreme.tdee);
|
||||
|
||||
// ===== 结果汇总 =====
|
||||
print('\n${'═' * 50}');
|
||||
print('📊 测试结果汇总');
|
||||
print('${'═' * 50}');
|
||||
print('✅ 通过: $_passCount');
|
||||
print('❌ 失败: $_failCount');
|
||||
print('📝 总计: ${_passCount + _failCount}');
|
||||
|
||||
if (_failMessages.isNotEmpty) {
|
||||
print('\n❌ 失败详情:');
|
||||
for (final msg in _failMessages) {
|
||||
print(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 检查Conicity Index公式是否正确 =====
|
||||
print('\n${'═' * 50}');
|
||||
print('🔍 Conicity Index 公式验证');
|
||||
print('${'═' * 50}');
|
||||
// 标准公式: CI = waist(m) / (0.109 * sqrt(weight(kg) / height(m)))
|
||||
// 我们代码中: waist(cm) / (0.109 * sqrt(weight / height))
|
||||
// 这里有单位问题!waist应该用米,但代码用的是厘米
|
||||
final ciCalc = maleWithOptional.conicityIndex;
|
||||
print('当前计算结果: $ciCalc');
|
||||
print('期望范围: 1.0-1.5 (正常人体范围)');
|
||||
if (ciCalc > 10) {
|
||||
print('⚠️ 发现问题: Conicity Index值异常大,可能waist单位错误');
|
||||
print(' 修正: 应将waist从cm转为m再计算');
|
||||
print(' 公式应为: waist_m / (0.109 * sqrt(weight / height_m))');
|
||||
}
|
||||
|
||||
print('\n🏁 验证完成');
|
||||
if (_failCount > 0) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
248
scripts/test_docs_gee_export.dart
Normal file
248
scripts/test_docs_gee_export.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
// 2026-04-24 | test_docs_gee_export.dart | docs_gee导出功能验证脚本 | 验证DOCX/PDF导出流程
|
||||
// 运行: dart run scripts/test_docs_gee_export.dart
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:docs_gee/docs_gee.dart' as gee;
|
||||
|
||||
void main() async {
|
||||
print('=== docs_gee 导出功能验证 ===\n');
|
||||
|
||||
await testBasicDocument();
|
||||
await testDocxGeneration();
|
||||
await testPdfGeneration();
|
||||
await testReplaceAllTypeIssue();
|
||||
await testFullRecipeExport();
|
||||
|
||||
print('\n=== 全部测试完成 ===');
|
||||
}
|
||||
|
||||
Future<void> testBasicDocument() async {
|
||||
print('[1] 基础文档构建...');
|
||||
try {
|
||||
final doc = gee.Document(title: '测试菜谱', author: '小妈厨房');
|
||||
doc.addParagraph(gee.Paragraph.heading('番茄炒蛋', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('这是一道经典家常菜'));
|
||||
doc.addParagraph(gee.Paragraph.heading('食材', level: 2));
|
||||
|
||||
final rows = [
|
||||
gee.TableRow(cells: [gee.TableCell.text('名称'), gee.TableCell.text('用量')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('番茄'), gee.TableCell.text('2个')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('鸡蛋'), gee.TableCell.text('3个')]),
|
||||
];
|
||||
doc.addTable(gee.Table(rows: rows));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('步骤', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('打散鸡蛋'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('热油炒蛋'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('加入番茄翻炒'));
|
||||
|
||||
print(' ✅ 文档构建成功');
|
||||
} catch (e, s) {
|
||||
print(' 文档构建失败: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testDocxGeneration() async {
|
||||
print('\n[2] DOCX 生成...');
|
||||
try {
|
||||
final doc = gee.Document(title: '测试DOCX', author: '验证脚本');
|
||||
doc.addParagraph(gee.Paragraph.heading('DOCX测试', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('验证Word文档生成'));
|
||||
final rows = [
|
||||
gee.TableRow(cells: [gee.TableCell.text('A'), gee.TableCell.text('B')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('1'), gee.TableCell.text('2')]),
|
||||
];
|
||||
doc.addTable(gee.Table(rows: rows));
|
||||
|
||||
final bytes = gee.DocxGenerator().generate(doc);
|
||||
final outDir = Directory('test_output');
|
||||
if (!await outDir.exists()) await outDir.create();
|
||||
final file = File('${outDir.path}/test_docx.docx');
|
||||
await file.writeAsBytes(bytes);
|
||||
print(' ✅ DOCX生成成功: ${file.path} (${bytes.length} bytes)');
|
||||
} catch (e, s) {
|
||||
print(' ❌ DOCX生成失败: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testPdfGeneration() async {
|
||||
print('\n[3] PDF 生成...');
|
||||
try {
|
||||
final doc = gee.Document(title: '测试PDF', author: '验证脚本');
|
||||
doc.addParagraph(gee.Paragraph.heading('PDF测试', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('验证PDF文档生成'));
|
||||
final bytes = gee.PdfGenerator().generate(doc);
|
||||
final outDir = Directory('test_output');
|
||||
if (!await outDir.exists()) await outDir.create();
|
||||
final file = File('${outDir.path}/test_pdf.pdf');
|
||||
await file.writeAsBytes(bytes);
|
||||
print(' ✅ PDF生成成功: ${file.path} (${bytes.length} bytes)');
|
||||
} catch (e, s) {
|
||||
print(' ❌ PDF生成失败: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testReplaceAllTypeIssue() async {
|
||||
print('\n[4] replaceAll 类型问题验证...');
|
||||
try {
|
||||
String fileName = '菇笋萝卜豆腐汤<测试>.docx';
|
||||
print(' 原始文件名: $fileName');
|
||||
|
||||
var sanitized = fileName.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
|
||||
print(' 替换后文件名: $sanitized');
|
||||
print(' ✅ replaceAll 正常工作');
|
||||
} catch (e, s) {
|
||||
print(' ❌ replaceAll 失败: $e\n$s');
|
||||
print(' 🔍 这是导致报错的根因!');
|
||||
}
|
||||
|
||||
print('\n 测试 dynamic 类型的 title:');
|
||||
try {
|
||||
dynamic title = '菇笋萝卜豆腐汤';
|
||||
String fileName2 = '$title<特殊>.docx';
|
||||
var sanitized2 = fileName2.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
|
||||
print(' 动态title替换结果: $sanitized2');
|
||||
print(' ✅ 动态类型也正常');
|
||||
} catch (e, s) {
|
||||
print(' ❌ 动态类型替换失败: $e\n$s');
|
||||
}
|
||||
|
||||
print('\n 测试 recipe.title 为 null:');
|
||||
try {
|
||||
dynamic title = null;
|
||||
String fileName3 = '${title ?? "未知菜谱"}.docx';
|
||||
var sanitized3 = fileName3.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_');
|
||||
print(' null title替换结果: $sanitized3');
|
||||
print(' ✅ null处理正常');
|
||||
} catch (e, s) {
|
||||
print(' ❌ null处理失败: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testFullRecipeExport() async {
|
||||
print('\n[5] 完整菜谱模拟导出...');
|
||||
|
||||
final recipe = _MockRecipe(
|
||||
title: '菇笋萝卜豆腐汤',
|
||||
categoryName: '美容养颜食谱',
|
||||
displayIntro: '清淡营养,适合四季食用',
|
||||
content: '1. 将所有食材洗净切块\n2. 锅中加水烧开\n3. 依次放入食材煮熟',
|
||||
ingredients: [
|
||||
_MockIngredient(name: '胡萝卜', amount: '100', unit: 'g'),
|
||||
_MockIngredient(name: '虾仁', amount: '50', unit: 'g'),
|
||||
_MockIngredient(name: '青豆', amount: '30', unit: 'g'),
|
||||
],
|
||||
tags: [
|
||||
_MockTag(name: '汤类'),
|
||||
_MockTag(name: '养生'),
|
||||
],
|
||||
);
|
||||
|
||||
try {
|
||||
ToastService.show = (msg) => print(' 📢 $msg');
|
||||
|
||||
final doc = gee.Document(title: recipe.title, author: '小妈厨房');
|
||||
doc.addParagraph(gee.Paragraph.heading(recipe.title, level: 1));
|
||||
|
||||
if (recipe.categoryName != null)
|
||||
doc.addParagraph(gee.Paragraph.text('📂 分类: ${recipe.categoryName}'));
|
||||
|
||||
if (recipe.displayIntro != null && recipe.displayIntro!.isNotEmpty)
|
||||
doc.addParagraph(gee.Paragraph.quote(recipe.displayIntro!));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('🥘 食材', level: 2));
|
||||
if (recipe.ingredients.isNotEmpty) {
|
||||
final rows = [
|
||||
gee.TableRow(
|
||||
cells: [gee.TableCell.text('食材名称'), gee.TableCell.text('用量')],
|
||||
),
|
||||
];
|
||||
for (final ing in recipe.ingredients) {
|
||||
rows.add(
|
||||
gee.TableRow(
|
||||
cells: [
|
||||
gee.TableCell.text(ing.name),
|
||||
gee.TableCell.text('${ing.amount ?? ""}${ing.unit ?? ""}'.trim()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
doc.addTable(gee.Table(rows: rows));
|
||||
}
|
||||
|
||||
if (recipe.content != null && recipe.content!.isNotEmpty) {
|
||||
doc.addParagraph(gee.Paragraph.heading('👨🍳 制作步骤', level: 2));
|
||||
final steps = recipe.content!
|
||||
.split(RegExp(r'\n+'))
|
||||
.where((s) => s.trim().isNotEmpty)
|
||||
.toList();
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
doc.addParagraph(gee.Paragraph.numberedItem(steps[i].trim()));
|
||||
}
|
||||
}
|
||||
|
||||
if ((recipe.tags as List).isNotEmpty) {
|
||||
doc.addParagraph(gee.Paragraph.heading('🏷️ 标签', level: 2));
|
||||
final tagNames = recipe.tags.map((t) => t.name).join(' · ');
|
||||
doc.addParagraph(gee.Paragraph.bulletItem(tagNames));
|
||||
}
|
||||
|
||||
final bytes = gee.DocxGenerator().generate(doc);
|
||||
final outDir = Directory('test_output');
|
||||
if (!await outDir.exists()) await outDir.create();
|
||||
|
||||
final dir = Directory('/storage/emulated/0/Download');
|
||||
String filePath;
|
||||
if (await dir.exists()) {
|
||||
final sanitized = '${recipe.title}.docx'.replaceAll(
|
||||
RegExp(r'[<>:"/\\|?*]'),
|
||||
'_',
|
||||
);
|
||||
filePath = '${dir.path}/$sanitized';
|
||||
} else {
|
||||
filePath = '${outDir.path}/${recipe.title}.docx';
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(bytes);
|
||||
print(' ✅ 完整导出成功: ${file.path} (${bytes.length} bytes)');
|
||||
} catch (e, s) {
|
||||
print(' ❌ 完整导出失败: $e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
class _MockRecipe {
|
||||
final String title;
|
||||
final String? categoryName;
|
||||
final String? displayIntro;
|
||||
final String? content;
|
||||
final List<_MockIngredient> ingredients;
|
||||
final List<_MockTag> tags;
|
||||
|
||||
_MockRecipe({
|
||||
required this.title,
|
||||
this.categoryName,
|
||||
this.displayIntro,
|
||||
this.content,
|
||||
required this.ingredients,
|
||||
required this.tags,
|
||||
});
|
||||
}
|
||||
|
||||
class _MockIngredient {
|
||||
final String name;
|
||||
final String? amount;
|
||||
final String? unit;
|
||||
|
||||
_MockIngredient({required this.name, this.amount, this.unit});
|
||||
}
|
||||
|
||||
class _MockTag {
|
||||
final String name;
|
||||
|
||||
_MockTag({required this.name});
|
||||
}
|
||||
|
||||
class ToastService {
|
||||
static void Function(String message)? show;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 2026-04-22 | test_export_import.dart | 数据导出/导入逻辑验证 | 验证JSON格式正确性和导入识别
|
||||
// 2026-04-24 | test_export_import.dart | 数据导出/导入逻辑验证 | 验证JSON格式正确性、导入识别、UTF-8编码、元数据
|
||||
// 运行: dart run scripts/test_export_import.dart
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -23,35 +23,94 @@ String _exportSingleSourceJson(DataSource source) {
|
||||
switch (source) {
|
||||
case DataSource.favorites:
|
||||
return const JsonEncoder.withIndent(' ').convert([
|
||||
{'id': 1, 'title': '红烧肉', 'favoriteType': 'recipe', 'favoriteAt': '2026-04-22'},
|
||||
{
|
||||
'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': '个', 'isChecked': false},
|
||||
{
|
||||
'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},
|
||||
{
|
||||
'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([
|
||||
{'recipeId': 1, 'content': '少放盐', 'tags': ['tips']},
|
||||
{
|
||||
'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': {'Mon': {'breakfast': 1}},
|
||||
'dailyMenus': {
|
||||
'2026-04-21': {
|
||||
'breakfast': {'recipeId': 1, 'recipeTitle': '豆浆'},
|
||||
'lunch': {'recipeId': 2, 'recipeTitle': '红烧肉'},
|
||||
},
|
||||
},
|
||||
});
|
||||
case DataSource.browseHistory:
|
||||
return const JsonEncoder.withIndent(' ').convert([
|
||||
{'recipeId': 1, 'title': '红烧肉', 'viewCount': 3, 'viewedAt': '2026-04-22'},
|
||||
{
|
||||
'id': 'hist_1',
|
||||
'recipeId': '1',
|
||||
'title': '红烧肉',
|
||||
'coverImage': null,
|
||||
'category': '家常菜',
|
||||
'viewedAt': '2026-04-22',
|
||||
'viewCount': 3,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 新版 exportAll:先构建Map再编码
|
||||
String exportAllNew() {
|
||||
final Map<String, dynamic> allData = {};
|
||||
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 {
|
||||
@@ -63,101 +122,340 @@ String exportAllNew() {
|
||||
return const JsonEncoder.withIndent(' ').convert(allData);
|
||||
}
|
||||
|
||||
/// previewImport:标准格式识别 + List格式自动推断
|
||||
({Map<DataSource, int> sourceCounts, String? error}) previewImport(String jsonContent) {
|
||||
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) {
|
||||
result[source] = data.length;
|
||||
if (data.isNotEmpty) result[source] = data.length;
|
||||
} else if (data is Map && source == DataSource.weeklyMenu) {
|
||||
result[source] = data.length;
|
||||
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>);
|
||||
final inferred = _inferDataSource(
|
||||
decoded.first as Map<String, dynamic>,
|
||||
);
|
||||
result[inferred ?? DataSource.favorites] = decoded.length;
|
||||
} else {
|
||||
} else if (decoded.isNotEmpty) {
|
||||
result[DataSource.favorites] = decoded.length;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return (sourceCounts: result, error: e.toString());
|
||||
return (sourceCounts: result, error: 'JSON解析失败: $e');
|
||||
}
|
||||
return (sourceCounts: result, error: null);
|
||||
}
|
||||
|
||||
DataSource? _inferDataSource(Map<String, dynamic> item) {
|
||||
if (item.containsKey('favoriteType') || 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;
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 测试 ───
|
||||
|
||||
void main() {
|
||||
print('╔══════════════════════════════════════════════════╗');
|
||||
print('║ 📦 数据导出/导入 逻辑验证 ║');
|
||||
print('╚══════════════════════════════════════════════════╝\n');
|
||||
int _passCount = 0;
|
||||
int _failCount = 0;
|
||||
|
||||
testFullExport();
|
||||
testSingleSourceImport();
|
||||
|
||||
print('\n╔══════════════════════════════════════════════════╗');
|
||||
print('║ ✅ 全部测试完成 ║');
|
||||
print('╚══════════════════════════════════════════════════╝');
|
||||
void _check(String name, bool condition, {String? detail}) {
|
||||
if (condition) {
|
||||
_passCount++;
|
||||
print(' ✅ $name');
|
||||
} else {
|
||||
_failCount++;
|
||||
print(' ❌ $name${detail != null ? ' — $detail' : ''}');
|
||||
}
|
||||
}
|
||||
|
||||
void testFullExport() {
|
||||
print('━━━ 1. 全量导出JSON格式验证 ━━━');
|
||||
final json = exportAllNew();
|
||||
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>;
|
||||
print(' ✅ JSON可解析,${decoded.length} 个数据源');
|
||||
for (final key in decoded.keys) {
|
||||
final val = decoded[key];
|
||||
final count = val is List ? val.length : (val is Map ? val.length : 0);
|
||||
print(' - "$key": ${val.runtimeType} ($count项)');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ JSON解析失败: $e');
|
||||
return;
|
||||
}
|
||||
_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);
|
||||
if (preview.error != null) {
|
||||
print(' ❌ previewImport失败: ${preview.error}');
|
||||
} else {
|
||||
print(' ✅ 识别 ${preview.sourceCounts.length}/${DataSource.values.length} 个数据源');
|
||||
for (final entry in preview.sourceCounts.entries) {
|
||||
print(' - ${entry.key.label}: ${entry.value} 条');
|
||||
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 testSingleSourceImport() {
|
||||
print('━━━ 2. 单数据源导入自动推断 ━━━');
|
||||
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;
|
||||
final ok = e.key == source ? '✅' : '⚠️ 偏移';
|
||||
print(' $ok ${source.label} → 推断为 ${e.key.label} (${e.value}条)');
|
||||
_check(
|
||||
'${source.label} List推断正确',
|
||||
e.key == source,
|
||||
detail: '期望: ${source.fileName}, 实际: ${e.key.fileName}',
|
||||
);
|
||||
} else {
|
||||
print(' ❌ ${source.label} 无法识别');
|
||||
_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('');
|
||||
}
|
||||
|
||||
202
scripts/test_json_import_fix.dart
Normal file
202
scripts/test_json_import_fix.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
// 2026-04-24 | test_json_import_fix.dart | 鸿蒙端JSON导入解析失败诊断脚本
|
||||
// 诊断文件: 小妈厨房 - 数据导出.json
|
||||
// 错误: FormatException: Unexpected character (at line 116, character 2)
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
void main() async {
|
||||
final filePath = r'e:\Program Files (x86)\WeGame\apps\2821981550\FileRecv\小妈厨房 - 数据导出.json';
|
||||
final file = File(filePath);
|
||||
|
||||
if (!await file.exists()) {
|
||||
print('❌ 文件不存在: $filePath');
|
||||
return;
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
print('📁 文件大小: ${bytes.length} bytes');
|
||||
|
||||
_checkBom(bytes);
|
||||
_checkEncoding(bytes);
|
||||
_checkLine116(bytes);
|
||||
_tryParseWithFixes(bytes);
|
||||
}
|
||||
|
||||
void _checkBom(Uint8List bytes) {
|
||||
print('\n=== BOM 检测 ===');
|
||||
if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) {
|
||||
print('✅ 检测到 UTF-8 BOM (EF BB BF) 在位置 0');
|
||||
} else if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) {
|
||||
print('✅ 检测到 UTF-16 LE BOM (FF FE)');
|
||||
} else if (bytes.length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) {
|
||||
print('✅ 检测到 UTF-16 BE BOM (FE FF)');
|
||||
} else {
|
||||
print('❌ 未检测到 BOM');
|
||||
}
|
||||
|
||||
if (bytes.isNotEmpty) {
|
||||
print('前20字节 hex: ${bytes.take(20).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
|
||||
print('前20字节 char: ${bytes.take(20).map((b) => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join()}');
|
||||
}
|
||||
}
|
||||
|
||||
void _checkEncoding(Uint8List bytes) {
|
||||
print('\n=== 编码检测 ===');
|
||||
|
||||
try {
|
||||
final utf8Str = utf8.decode(bytes, allowMalformed: true);
|
||||
print('UTF-8 解码成功,长度: ${utf8Str.length}');
|
||||
print('前100字符: ${utf8Str.substring(0, utf8Str.length.clamp(0, 100))}');
|
||||
|
||||
final hasBomChar = utf8Str.startsWith('\uFEFF');
|
||||
if (hasBomChar) {
|
||||
print('⚠️ 字符串以 BOM 字符 \\uFEFF 开头');
|
||||
}
|
||||
|
||||
final nullCount = '\x00'.allMatches(utf8Str).length;
|
||||
if (nullCount > 0) {
|
||||
print('⚠️ 发现 $nullCount 个 null 字节 (\\x00)');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ UTF-8 解码失败: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
final latin1Str = latin1.decode(bytes);
|
||||
print('Latin-1 解码成功,长度: ${latin1Str.length}');
|
||||
} catch (e) {
|
||||
print('❌ Latin-1 解码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _checkLine116(Uint8List bytes) {
|
||||
print('\n=== 第116行诊断 ===');
|
||||
|
||||
try {
|
||||
final content = utf8.decode(bytes, allowMalformed: true);
|
||||
final lines = content.split('\n');
|
||||
|
||||
print('总行数: ${lines.length}');
|
||||
|
||||
if (lines.length >= 116) {
|
||||
final line115 = lines.length > 115 ? lines[115] : '<不存在>';
|
||||
final line116 = lines.length > 116 ? lines[116] : '<不存在>';
|
||||
final line117 = lines.length > 117 ? lines[117] : '<不存在>';
|
||||
|
||||
print('第115行 (${line115.length}字符): ${line115.substring(0, line115.length.clamp(0, 200))}');
|
||||
print('第116行 (${line116.length}字符): ${line116.substring(0, line116.length.clamp(0, 200))}');
|
||||
print('第117行 (${line117.length}字符): ${line117.substring(0, line117.length.clamp(0, 200))}');
|
||||
|
||||
if (line116.length >= 2) {
|
||||
final charAt1 = line116.codeUnitAt(0);
|
||||
final charAt2 = line116.codeUnitAt(1);
|
||||
print('第116行字符1(位置0): U+${charAt1.toRadixString(16).padLeft(4, "0")} = ${_charInfo(charAt1)}');
|
||||
print('第116行字符2(位置1): U+${charAt2.toRadixString(16).padLeft(4, "0")} = ${_charInfo(charAt2)}');
|
||||
|
||||
if (charAt2 < 32 || charAt2 > 126) {
|
||||
print('⚠️ 第116行第2个字符是非ASCII/控制字符!');
|
||||
}
|
||||
}
|
||||
|
||||
print('\n第116行所有字符的码点:');
|
||||
for (var i = 0; i < line116.length.clamp(0, 50); i++) {
|
||||
final c = line116.codeUnitAt(i);
|
||||
if (c < 32 || c > 126) {
|
||||
print(' 位置$i: U+${c.toRadixString(16).padLeft(4, "0")} ${_charInfo(c)} ⚠️');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print('⚠️ 文件不足116行');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ 行分析失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
String _charInfo(int codeUnit) {
|
||||
if (codeUnit == 0) return 'NULL';
|
||||
if (codeUnit == 0xFEFF) return 'BOM (ZERO WIDTH NO-BREAK SPACE)';
|
||||
if (codeUnit == 9) return 'TAB';
|
||||
if (codeUnit == 10) return 'LF';
|
||||
if (codeUnit == 13) return 'CR';
|
||||
if (codeUnit < 32) return '控制字符';
|
||||
if (codeUnit <= 126) return "'${String.fromCharCode(codeUnit)}'";
|
||||
if (codeUnit <= 255) return 'Latin-1扩展';
|
||||
return 'Unicode ${String.fromCharCode(codeUnit)}';
|
||||
}
|
||||
|
||||
void _tryParseWithFixes(Uint8List bytes) {
|
||||
print('\n=== 尝试各种修复方案解析 ===');
|
||||
|
||||
try {
|
||||
var content = utf8.decode(bytes, allowMalformed: true);
|
||||
|
||||
print('\n方案1: 原始 utf8.decode(allowMalformed: true)');
|
||||
try {
|
||||
jsonDecode(content);
|
||||
print(' ✅ JSON 解析成功!');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
|
||||
print('\n方案2: 移除 BOM 字符');
|
||||
try {
|
||||
final trimmed = content.replaceFirst(RegExp('^\\uFEFF'), '');
|
||||
jsonDecode(trimmed);
|
||||
print(' ✅ JSON 解析成功!(移除BOM后)');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
|
||||
print('\n方案3: 移除所有控制字符(保留\\n\\r\\t)');
|
||||
try {
|
||||
final cleaned = content.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), '');
|
||||
jsonDecode(cleaned);
|
||||
print(' ✅ JSON 解析成功!(移除控制字符后)');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
|
||||
print('\n方案4: trim 后解析');
|
||||
try {
|
||||
jsonDecode(content.trim());
|
||||
print(' ✅ JSON 解析成功!(trim后)');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
|
||||
print('\n方案5: 完整清理流程 (推荐)');
|
||||
try {
|
||||
String cleaned = content;
|
||||
cleaned = cleaned.replaceFirst(RegExp('^\\uFEFF'), '');
|
||||
cleaned = cleaned.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), '');
|
||||
cleaned = cleaned.trim();
|
||||
final result = jsonDecode(cleaned);
|
||||
print(' ✅ JSON 解析成功!');
|
||||
|
||||
if (result is Map<String, dynamic>) {
|
||||
print(' 📊 顶层 keys: ${result.keys.toList()}');
|
||||
if (result.containsKey('_meta')) {
|
||||
print(' 📋 _meta: ${result["_meta"]}');
|
||||
}
|
||||
for (final key in result.keys) {
|
||||
if (key == '_meta') continue;
|
||||
final val = result[key];
|
||||
if (val is List) {
|
||||
print(' 📦 $key: ${val.length} 条数据');
|
||||
} else if (val is Map) {
|
||||
print(' 📦 $key: Map with ${val.length} entries');
|
||||
}
|
||||
}
|
||||
} else if (result is List) {
|
||||
print(' 📦 顶层是 List: ${result.length} 条数据');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
print('❌ UTF-8 解码失败: $e');
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
// 2026-04-17 | test_kitchen_api.dart | 点餐助手API接口测试 | 验证kitchen.php CRUD + SSE
|
||||
// 运行: dart run scripts/test_kitchen_api.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
const String baseUrl = 'https://eat.wktyl.com/api/kitchen';
|
||||
|
||||
String? createdOrderId;
|
||||
String? createdOrderNo;
|
||||
|
||||
int _curlCounter = 0;
|
||||
|
||||
Future<String> _curl(
|
||||
String method,
|
||||
String url, {
|
||||
String? body,
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
_curlCounter++;
|
||||
final tmpFile = File(
|
||||
'${Directory.systemTemp.path}/kitchen_test_$_curlCounter.json',
|
||||
);
|
||||
final args = [
|
||||
'-sS',
|
||||
'-X',
|
||||
method,
|
||||
'--max-time',
|
||||
'15',
|
||||
'--compressed',
|
||||
'-o',
|
||||
tmpFile.path,
|
||||
];
|
||||
if (headers != null) {
|
||||
headers.forEach((k, v) {
|
||||
args.addAll(['-H', '$k: $v']);
|
||||
});
|
||||
}
|
||||
if (body != null) {
|
||||
args.addAll([
|
||||
'-H',
|
||||
'Content-Type: application/json; charset=utf-8',
|
||||
'-d',
|
||||
body,
|
||||
]);
|
||||
}
|
||||
args.add(url);
|
||||
|
||||
final result = await Process.run('curl.exe', args);
|
||||
final stderr = (result.stderr as String).trim();
|
||||
if (!tmpFile.existsSync()) {
|
||||
throw Exception('curl无输出: $stderr');
|
||||
}
|
||||
final bytes = tmpFile.readAsBytesSync();
|
||||
try {
|
||||
tmpFile.deleteSync();
|
||||
} catch (_) {}
|
||||
if (bytes.isEmpty && stderr.isNotEmpty) {
|
||||
throw Exception('curl error: $stderr');
|
||||
}
|
||||
final output = utf8.decode(bytes, allowMalformed: true);
|
||||
return _extractJson(output);
|
||||
}
|
||||
|
||||
String _extractJson(String raw) {
|
||||
var s = raw.trim();
|
||||
final jsonStart = s.indexOf('{');
|
||||
if (jsonStart > 0) {
|
||||
s = s.substring(jsonStart);
|
||||
}
|
||||
final jsonEnd = s.lastIndexOf('}');
|
||||
if (jsonEnd >= 0 && jsonEnd < s.length - 1) {
|
||||
s = s.substring(0, jsonEnd + 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
print('╔══════════════════════════════════════════════════╗');
|
||||
print('║ 🍽️ 点餐助手 API 接口测试 ║');
|
||||
print('║ 目标: $baseUrl ║');
|
||||
print('╚══════════════════════════════════════════════════╝\n');
|
||||
|
||||
print('━━━ 1. 接口首页 (index) ━━━');
|
||||
await testGet({'act': 'index'});
|
||||
|
||||
print('\n━━━ 2. CORS预检 (OPTIONS) ━━━');
|
||||
await testOptions();
|
||||
|
||||
print('\n━━━ 3. 创建点单 (POST JSON body) ━━━');
|
||||
await testCreateOrder();
|
||||
|
||||
if (createdOrderId == null) {
|
||||
print('\n❌ 创建点单失败,后续测试跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
print('\n━━━ 4. 获取点单 (GET ?act=get&id=xxx) ━━━');
|
||||
await testGet({'act': 'get', 'id': createdOrderId!});
|
||||
|
||||
print('\n━━━ 5. 更新点单 (POST ?act=update) ━━━');
|
||||
await testUpdateOrder();
|
||||
|
||||
print('\n━━━ 6. 点单列表 (GET ?act=list) ━━━');
|
||||
await testGet({'act': 'list', 'page': '1', 'limit': '5'});
|
||||
|
||||
print('\n━━━ 7. 统计信息 (GET ?act=stats) ━━━');
|
||||
await testGet({'act': 'stats'});
|
||||
|
||||
print('\n━━━ 8. SSE连接测试 ━━━');
|
||||
await testSSE();
|
||||
|
||||
print('\n━━━ 9. 清理过期数据 (GET ?act=cleanup&days=999) ━━━');
|
||||
await testGet({'act': 'cleanup', 'days': '999'});
|
||||
|
||||
print('\n━━━ 10. 删除点单 (GET ?act=delete&id=xxx) ━━━');
|
||||
await testGet({'act': 'delete', 'id': createdOrderId!});
|
||||
|
||||
print('\n━━━ 11. 确认删除 - 再次获取 ━━━');
|
||||
await testGet({'act': 'get', 'id': createdOrderId!});
|
||||
|
||||
print('\n╔══════════════════════════════════════════════════╗');
|
||||
print('║ ✅ 全部测试完成 ║');
|
||||
print('╚══════════════════════════════════════════════════╝');
|
||||
}
|
||||
|
||||
Future<void> testGet(Map<String, String> params) async {
|
||||
final qs = params.entries
|
||||
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
final url = '$baseUrl/kitchen.php?$qs';
|
||||
print(' GET $url');
|
||||
try {
|
||||
final output = await _curl(
|
||||
'GET',
|
||||
url,
|
||||
headers: {'Accept': 'application/json'},
|
||||
);
|
||||
_printResult(output);
|
||||
} catch (e) {
|
||||
print(' ❌ 请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testOptions() async {
|
||||
final url = '$baseUrl/kitchen.php';
|
||||
print(' OPTIONS $url');
|
||||
try {
|
||||
final args = [
|
||||
'-sS',
|
||||
'-X',
|
||||
'OPTIONS',
|
||||
'-o',
|
||||
'NUL',
|
||||
'-w',
|
||||
'%{http_code}\\n%header{Access-Control-Allow-Origin}\\n%header{Access-Control-Allow-Methods}',
|
||||
'--max-time',
|
||||
'15',
|
||||
url,
|
||||
];
|
||||
final result = await Process.run('curl.exe', args);
|
||||
final output = (result.stdout as String).trim();
|
||||
final lines = output.split('\n');
|
||||
final statusCode = lines.isNotEmpty ? lines[0].trim() : '???';
|
||||
print(' 状态码: $statusCode');
|
||||
if (lines.length > 1) print(' Allow-Origin: ${lines[1].trim()}');
|
||||
if (lines.length > 2) print(' Allow-Methods: ${lines[2].trim()}');
|
||||
if (statusCode == '204') {
|
||||
print(' ✅ CORS预检通过');
|
||||
} else {
|
||||
print(' ⚠️ 预期204,实际$statusCode');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ 请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testCreateOrder() async {
|
||||
final orderData = {
|
||||
'type': 0,
|
||||
'status': 1,
|
||||
'tableNo': 'A1',
|
||||
'note': '接口测试订单,可删除',
|
||||
'items': [
|
||||
{
|
||||
'id': 'item_1',
|
||||
'name': '红烧肉',
|
||||
'source': 0,
|
||||
'quantity': 1,
|
||||
'price': 38.0,
|
||||
'ingredients': '五花肉、酱油、冰糖',
|
||||
'note': null,
|
||||
},
|
||||
{
|
||||
'id': 'item_2',
|
||||
'name': '番茄炒蛋',
|
||||
'source': 2,
|
||||
'quantity': 2,
|
||||
'price': 18.0,
|
||||
'ingredients': null,
|
||||
'note': '少盐',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final url = '$baseUrl/kitchen.php?act=create';
|
||||
print(' POST $url');
|
||||
try {
|
||||
final output = await _curl(
|
||||
'POST',
|
||||
url,
|
||||
body: jsonEncode(orderData),
|
||||
headers: {'Accept': 'application/json'},
|
||||
);
|
||||
print(' Raw: ${output.length > 500 ? output.substring(0, 500) : output}');
|
||||
final result = _parseResult(output);
|
||||
if (result['code'] == 200 && result['data'] != null) {
|
||||
final data = result['data'] as Map<String, dynamic>;
|
||||
createdOrderId = data['id']?.toString();
|
||||
createdOrderNo = data['orderNo']?.toString();
|
||||
print(' ✅ 创建成功! id=$createdOrderId, orderNo=$createdOrderNo');
|
||||
} else {
|
||||
print(' ❌ 创建失败: ${result['message']}');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ 请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testUpdateOrder() async {
|
||||
final updateData = {
|
||||
'id': createdOrderId,
|
||||
'status': 2,
|
||||
'note': '接口测试 - 已更新',
|
||||
'items': [
|
||||
{
|
||||
'id': 'item_1',
|
||||
'name': '红烧肉',
|
||||
'source': 0,
|
||||
'quantity': 2,
|
||||
'price': 38.0,
|
||||
'ingredients': '五花肉、酱油、冰糖',
|
||||
'note': '多放糖',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
final url = '$baseUrl/kitchen.php?act=update';
|
||||
print(' POST $url');
|
||||
try {
|
||||
final output = await _curl(
|
||||
'POST',
|
||||
url,
|
||||
body: jsonEncode(updateData),
|
||||
headers: {'Accept': 'application/json'},
|
||||
);
|
||||
_printResult(output);
|
||||
} catch (e) {
|
||||
print(' ❌ 请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> testSSE() async {
|
||||
final url = '$baseUrl/kitchen_sse.php?order_id=${createdOrderId ?? "test"}';
|
||||
print(' GET (SSE) $url');
|
||||
try {
|
||||
_curlCounter++;
|
||||
final tmpFile = File(
|
||||
'${Directory.systemTemp.path}/kitchen_sse_test_$_curlCounter.txt',
|
||||
);
|
||||
final args = [
|
||||
'-sS',
|
||||
'-N',
|
||||
'--max-time',
|
||||
'6',
|
||||
'--compressed',
|
||||
'-o',
|
||||
tmpFile.path,
|
||||
'-H',
|
||||
'Accept: text/event-stream',
|
||||
'-H',
|
||||
'Cache-Control: no-cache',
|
||||
url,
|
||||
];
|
||||
await Process.run('curl.exe', args);
|
||||
if (!tmpFile.existsSync()) {
|
||||
print(' ⚠️ SSE无输出');
|
||||
return;
|
||||
}
|
||||
final bytes = tmpFile.readAsBytesSync();
|
||||
try {
|
||||
tmpFile.deleteSync();
|
||||
} catch (_) {}
|
||||
final output = utf8.decode(bytes, allowMalformed: true).trim();
|
||||
if (output.isEmpty) {
|
||||
print(' ⚠️ SSE无输出');
|
||||
return;
|
||||
}
|
||||
final lines = output.split('\n').where((l) => l.trim().isNotEmpty).toList();
|
||||
print(' 收到 ${lines.length} 行SSE数据:');
|
||||
for (var i = 0; i < lines.length && i < 10; i++) {
|
||||
print(' 📡 ${lines[i]}');
|
||||
}
|
||||
final hasConnected = lines.any((l) => l.contains('connected'));
|
||||
final hasHeartbeat = lines.any((l) => l.contains('heartbeat'));
|
||||
final hasOrderUpdate = lines.any((l) => l.contains('order_update'));
|
||||
if (hasConnected) print(' ✅ SSE连接事件已收到');
|
||||
if (hasHeartbeat) print(' ✅ SSE心跳事件已收到');
|
||||
if (hasOrderUpdate) print(' ✅ SSE订单更新事件已收到');
|
||||
if (!hasConnected && !hasHeartbeat) print(' ⚠️ 未收到标准SSE事件');
|
||||
} catch (e) {
|
||||
print(' ❌ SSE请求失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _parseResult(String body) {
|
||||
try {
|
||||
return jsonDecode(body) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return {'code': -1, 'message': 'JSON解析失败', 'raw': body};
|
||||
}
|
||||
}
|
||||
|
||||
void _printResult(String body) {
|
||||
try {
|
||||
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||
final code = json['code'];
|
||||
final message = json['message'];
|
||||
if (code == 200) {
|
||||
print(' ✅ code: $code | $message');
|
||||
final data = json['data'];
|
||||
if (data != null) {
|
||||
final dataStr = jsonEncode(data);
|
||||
if (dataStr.length > 300) {
|
||||
print(' data: ${dataStr.substring(0, 300)}...');
|
||||
} else {
|
||||
print(' data: $dataStr');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print(' ⚠️ code: $code | $message');
|
||||
final data = json['data'];
|
||||
if (data != null) print(' data: ${jsonEncode(data)}');
|
||||
}
|
||||
} catch (e) {
|
||||
if (body.length > 300) {
|
||||
print(' ${body.substring(0, 300)}...');
|
||||
} else {
|
||||
print(' $body');
|
||||
}
|
||||
}
|
||||
}
|
||||
394
scripts/test_pdf_export_verify.dart
Normal file
394
scripts/test_pdf_export_verify.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
// 2026-04-25 | test_pdf_export_verify.dart | 验证PDF导出功能
|
||||
// 验证: 中文显示、字体嵌入、图片嵌入、文件结构完整性
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
void main() async {
|
||||
print('========================================');
|
||||
print(' PDF 导出功能验证脚本');
|
||||
print('========================================\n');
|
||||
|
||||
await _test1_PdfGeneratorCreation();
|
||||
await _test2_ChineseTextRendering();
|
||||
await _test3_EmbeddedFontSupport();
|
||||
await _test4_ImageEmbedding();
|
||||
await _test5_FullRecipeExport();
|
||||
await _test6_OutputFileStructure();
|
||||
|
||||
print('\n========================================');
|
||||
print(' 全部测试完成');
|
||||
print('========================================');
|
||||
}
|
||||
|
||||
Future<void> _test1_PdfGeneratorCreation() async {
|
||||
print('[测试1] PdfGenerator 基础创建...');
|
||||
try {
|
||||
final pdfGen = gee.PdfGenerator(fontSize: 12);
|
||||
final doc = gee.Document(title: 'Test', author: 'Tester');
|
||||
doc.addParagraph(gee.Paragraph.text('Hello World'));
|
||||
final bytes = pdfGen.generate(doc);
|
||||
|
||||
if (bytes.isNotEmpty && bytes.length > 100) {
|
||||
print(' ✅ 基础PDF生成成功 (${bytes.length} bytes)');
|
||||
} else {
|
||||
print(' ❌ PDF字节过短或为空');
|
||||
}
|
||||
} catch (e) {
|
||||
print(' ❌ 创建失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test2_ChineseTextRendering() async {
|
||||
print('\n[测试2] 中文文本渲染(无嵌入字体)...');
|
||||
try {
|
||||
final pdfGen = gee.PdfGenerator(fontSize: 13);
|
||||
final doc = gee.Document(title: '中文测试', author: '小妈厨房');
|
||||
doc.addParagraph(gee.Paragraph.heading('菇笋萝卜豆腐汤', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('这是一道美容养颜的汤品'));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('胡萝卜 100g'));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('虾仁 50g'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('将所有食材洗净切块'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('锅中加水烧开'));
|
||||
final bytes = pdfGen.generate(doc);
|
||||
|
||||
if (_isValidPdf(bytes)) {
|
||||
print(' ✅ PDF结构有效 (${bytes.length} bytes)');
|
||||
print(' ⚠️ 注意: 无嵌入字体时中文可能显示为 ???');
|
||||
} else {
|
||||
print(' ❌ PDF结构无效');
|
||||
}
|
||||
|
||||
_saveTestFile(bytes, 'test2_chinese_no_font.pdf');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test3_EmbeddedFontSupport() async {
|
||||
print('\n[测试3] 嵌入中文字体支持...');
|
||||
try {
|
||||
final fontPath = 'assets/fonts/NotoSansSC-Regular.otf';
|
||||
final boldFontPath = 'assets/fonts/NotoSansSC-Bold.otf';
|
||||
|
||||
final fontFile = File(fontPath);
|
||||
final boldFontFile = File(boldFontPath);
|
||||
|
||||
Uint8List? fontBytes;
|
||||
Uint8List? boldFontBytes;
|
||||
|
||||
if (await fontFile.exists()) {
|
||||
fontBytes = await fontFile.readAsBytes();
|
||||
print(' 📝 加载常规字体: ${fontBytes!.length} bytes');
|
||||
} else {
|
||||
print(' ⚠️ 字体文件不存在: $fontPath');
|
||||
}
|
||||
|
||||
if (await boldFontFile.exists()) {
|
||||
boldFontBytes = await boldFontFile.readAsBytes();
|
||||
print(' 📝 加载粗体字体: ${boldFontBytes!.length} bytes');
|
||||
} else {
|
||||
print(' ⚠️ 粗体字体不存在: $boldFontPath');
|
||||
}
|
||||
|
||||
final pdfGen = gee.PdfGenerator(
|
||||
fontSize: 13,
|
||||
embeddedFontBytes: fontBytes,
|
||||
embeddedBoldFontBytes: boldFontBytes,
|
||||
);
|
||||
|
||||
final doc = gee.Document(title: '中文菜谱', author: '小妈厨房');
|
||||
doc.addParagraph(gee.Paragraph.heading('🍲 菇笋萝卜豆腐汤', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('分类: 美容养颜食谱'));
|
||||
doc.addParagraph(gee.Paragraph.quote('清淡营养,适合四季食用'));
|
||||
doc.addParagraph(gee.Paragraph.heading('食材', level: 2));
|
||||
doc.addTable(gee.Table(rows: [
|
||||
gee.TableRow(cells: [gee.TableCell.text('名称'), gee.TableCell.text('用量')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('胡萝卜'), gee.TableCell.text('100g')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('虾仁'), gee.TableCell.text('50g')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('青豆'), gee.TableCell.text('30g')]),
|
||||
]));
|
||||
doc.addParagraph(gee.Paragraph.heading('制作步骤', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('将所有食材洗净切块'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('锅中加水,放入胡萝卜煮3分钟'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('加入虾仁和青豆煮熟即可'));
|
||||
doc.addParagraph(gee.Paragraph.heading('标签', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('汤类 · 养生 · 家常菜'));
|
||||
|
||||
final bytes = pdfGen.generate(doc);
|
||||
|
||||
if (_isValidPdf(bytes)) {
|
||||
print(' ✅ 带嵌入字体的PDF生成成功 (${bytes.length} bytes)');
|
||||
|
||||
final hasFontDesc = _containsBytes(bytes, '/FontDescriptor');
|
||||
final hasCIDFont = _containsBytes(bytes, '/CIDFontType2');
|
||||
final hasIdentityH = _containsBytes(bytes, '/Identity-H');
|
||||
|
||||
print(' FontDescriptor: ${hasFontDesc ? "✅" : "❌"}');
|
||||
print(' CIDFontType2: ${hasCIDFont ? "✅" : "❌"}');
|
||||
print(' Identity-H: ${hasIdentityH ? "✅" : "❌"}');
|
||||
|
||||
if (hasFontDesc && hasCIDFont && hasIdentityH) {
|
||||
print(' ✅ CJK字体嵌入完整!');
|
||||
}
|
||||
} else {
|
||||
print(' ❌ PDF结构无效');
|
||||
}
|
||||
|
||||
_saveTestFile(bytes, 'test3_cjk_font.pdf');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test4_ImageEmbedding() async {
|
||||
print('\n[测试4] 图片嵌入测试...');
|
||||
|
||||
Uint8List? fakeJpegBytes;
|
||||
try {
|
||||
fakeJpegBytes = _createFakeJpeg(200, 150);
|
||||
print(' 📷 生成模拟JPEG图片: ${fakeJpegBytes!.length} bytes');
|
||||
} catch (e) {
|
||||
print(' ⚠️ 无法创建模拟图片: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
final pdfGen = gee.PdfGenerator(
|
||||
fontSize: 13,
|
||||
coverImageBytes: fakeJpegBytes,
|
||||
coverImageWidth: 300,
|
||||
coverImageHeight: 225,
|
||||
);
|
||||
|
||||
final doc = gee.Document(title: '带图片的文档', author: 'Tester');
|
||||
doc.addParagraph(gee.Paragraph.heading('菜品展示', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('上面是这道菜的精美图片'));
|
||||
doc.addParagraph(gee.Paragraph.quote('色香味俱全'));
|
||||
|
||||
final bytes = pdfGen.generate(doc);
|
||||
|
||||
if (_isValidPdf(bytes)) {
|
||||
print(' ✅ 带图片的PDF生成成功 (${bytes.length} bytes)');
|
||||
|
||||
final hasXObject = _containsBytes(bytes, '/XObject');
|
||||
final hasImage = _containsBytes(bytes, '/Subtype /Image');
|
||||
final hasIm0 = _containsBytes(bytes, '/Im0');
|
||||
|
||||
print(' XObject资源: ${hasXObject ? "✅" : "❌"}');
|
||||
print(' Image对象: ${hasImage ? "✅" : "❌"}');
|
||||
print(' Im0引用: ${hasIm0 ? "✅" : "❌"}');
|
||||
}
|
||||
|
||||
_saveTestFile(bytes, 'test4_with_image.pdf');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test5_FullRecipeExport() async {
|
||||
print('\n[测试5] 完整菜谱导出(字体+图片)...');
|
||||
|
||||
try {
|
||||
final fontFile = File('assets/fonts/NotoSansSC-Regular.otf');
|
||||
final boldFontFile = File('assets/fonts/NotoSansSC-Bold.otf');
|
||||
|
||||
final fontBytes = await fontFile.exists() ? await fontFile.readAsBytes() : null;
|
||||
final boldFontBytes = await boldFontFile.exists() ? await boldFontFile.readAsBytes() : null;
|
||||
final imgBytes = _createFakeJpeg(400, 300);
|
||||
|
||||
final pdfGen = gee.PdfGenerator(
|
||||
fontSize: 13,
|
||||
embeddedFontBytes: fontBytes,
|
||||
embeddedBoldFontBytes: boldFontBytes,
|
||||
coverImageBytes: imgBytes,
|
||||
coverImageWidth: 360,
|
||||
coverImageHeight: 270,
|
||||
);
|
||||
|
||||
final doc = gee.Document(title: '菇笋萝卜豆腐汤', author: '小妈厨房');
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('🍲 菇笋萝卜豆腐汤', level: 1));
|
||||
doc.addParagraph(gee.Paragraph.text('📂 分类: 美容养颜食谱'));
|
||||
doc.addParagraph(gee.Paragraph.text('✍️ 作者: 小妈厨房'));
|
||||
doc.addParagraph(gee.Paragraph.quote('清淡营养,适合四季食用。这道汤品富含多种维生素和蛋白质。'));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('🥘 食材清单', level: 2));
|
||||
doc.addTable(gee.Table(rows: [
|
||||
gee.TableRow(cells: [gee.TableCell.text('食材'), gee.TableCell.text('用量')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('新鲜香菇'), gee.TableCell.text('100g')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('冬笋'), gee.TableCell.text('80g')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('白萝卜'), gee.TableCell.text('150g')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('嫩豆腐'), gee.TableCell.text('1块')]),
|
||||
gee.TableRow(cells: [gee.TableCell.text('枸杞'), gee.TableCell.text('10粒')]),
|
||||
]));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('👨🍳 制作步骤', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('香菇用温水泡发后切片,冬笋去壳切片焯水去涩'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('白萝卜去皮切滚刀块,豆腐切成2cm见方的小块'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('锅中加清水1200ml,放入萝卜块大火煮开转中火10分钟'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('加入香菇片、冬笋片继续煮5分钟'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('轻轻放入豆腐块,加盐调味,小火炖3分钟'));
|
||||
doc.addParagraph(gee.Paragraph.numberedItem('撒入枸杞,关火焖1分钟即可上桌'));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('💡 小贴士', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('豆腐要最后放,避免煮散'));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('萝卜先煮更入味'));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('可根据个人口味加入少许胡椒粉'));
|
||||
|
||||
doc.addParagraph(gee.Paragraph.heading('🏷️ 标签', level: 2));
|
||||
doc.addParagraph(gee.Paragraph.bulletItem('汤类 · 养生 · 家常菜 · 四季适用'));
|
||||
|
||||
final bytes = pdfGen.generate(doc);
|
||||
|
||||
if (_isValidPdf(bytes)) {
|
||||
print(' ✅ 完整菜谱PDF生成成功 (${bytes.length} bytes)');
|
||||
|
||||
final checks = <String, bool>{
|
||||
'PDF头': _startsWithPdfHeader(bytes),
|
||||
'FontDescriptor': _containsBytes(bytes, '/FontDescriptor'),
|
||||
'CIDFont': _containsBytes(bytes, '/CIDFontType2'),
|
||||
'Identity-H编码': _containsBytes(bytes, '/Identity-H'),
|
||||
'XObject图片': _containsBytes(bytes, '/XObject'),
|
||||
'Image对象': _containsBytes(bytes, '/Subtype /Image'),
|
||||
'表格数据': _containsBytes(bytes, '香菇'),
|
||||
'步骤内容': _containsBytes(bytes, '豆腐块'),
|
||||
'EOF标记': _endsWithEof(bytes),
|
||||
};
|
||||
|
||||
for (final entry in checks.entries) {
|
||||
print(' ${entry.value ? "✅" : "❌"} ${entry.key}');
|
||||
}
|
||||
|
||||
final passCount = checks.values.where((b) => b).length;
|
||||
print('\n 通过率: $passCount/${checks.length}');
|
||||
}
|
||||
|
||||
_saveTestFile(bytes, 'test5_full_recipe.pdf');
|
||||
} catch (e) {
|
||||
print(' ❌ 失败: $e');
|
||||
print(' ${StackTrace.current}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test6_OutputFileStructure() async {
|
||||
print('\n[测试6] 输出文件结构分析...');
|
||||
|
||||
final dir = Directory('.');
|
||||
final files = await dir.list().where((f) => f.path.endsWith('.pdf')).toList();
|
||||
|
||||
if (files.isEmpty) {
|
||||
print(' ⚠️ 未找到生成的PDF文件');
|
||||
return;
|
||||
}
|
||||
|
||||
for (final file in files) {
|
||||
if (!file.path.contains('test')) continue;
|
||||
final name = file.uri.pathSegments.last;
|
||||
final stat = await file.stat();
|
||||
final bytes = await (file as File).readAsBytes();
|
||||
|
||||
print('\n 📄 $name');
|
||||
print(' 大小: ${stat.size} bytes (${(stat.size / 1024).toStringAsFixed(1)} KB)');
|
||||
print(' 有效PDF: ${_isValidPdf(bytes)}');
|
||||
|
||||
if (_containsBytes(bytes, '%PDF-')) {
|
||||
final version = String.fromCharCodes(bytes.sublist(0, 8)).trim();
|
||||
print(' 版本: $version');
|
||||
}
|
||||
|
||||
if (_containsBytes(bytes, '/FontDescriptor')) {
|
||||
print(' ✅ 包含嵌入字体');
|
||||
}
|
||||
if (_containsBytes(bytes, '/XObject')) {
|
||||
print(' ✅ 包含图片资源');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidPdf(Uint8List bytes) {
|
||||
return _startsWithPdfHeader(bytes) && _endsWithEof(bytes);
|
||||
}
|
||||
|
||||
bool _startsWithPdfHeader(Uint8List bytes) {
|
||||
if (bytes.length < 5) return false;
|
||||
return String.fromCharCodes(bytes.sublist(0, 5)).startsWith('%PDF-');
|
||||
}
|
||||
|
||||
bool _endsWithEof(Uint8List bytes) {
|
||||
if (bytes.length < 5) return false;
|
||||
final end = String.fromCharCodes(bytes.sublist(bytes.length - 5));
|
||||
return end.trim().endsWith('%%EOF');
|
||||
}
|
||||
|
||||
bool _containsBytes(Uint8List bytes, String pattern) {
|
||||
final patternBytes = utf8.encode(pattern);
|
||||
for (int i = 0; i <= bytes.length - patternBytes.length; i++) {
|
||||
bool match = true;
|
||||
for (int j = 0; j < patternBytes.length; j++) {
|
||||
if (bytes[i + j] != patternBytes[j]) { match = false; break; }
|
||||
}
|
||||
if (match) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Uint8List _createFakeJpeg(int width, int height) {
|
||||
final jpegHeader = [
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46,
|
||||
0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
|
||||
0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
||||
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08,
|
||||
0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C,
|
||||
0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
|
||||
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D,
|
||||
0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27, 0x20,
|
||||
0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
|
||||
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27,
|
||||
0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34,
|
||||
0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
|
||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4,
|
||||
0x00, 0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01,
|
||||
0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04,
|
||||
0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF,
|
||||
0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
|
||||
0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04,
|
||||
0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00,
|
||||
0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
|
||||
0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32,
|
||||
0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1,
|
||||
0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
|
||||
0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A,
|
||||
0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35,
|
||||
0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
|
||||
0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55,
|
||||
0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65,
|
||||
0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
|
||||
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85,
|
||||
0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94,
|
||||
0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
|
||||
0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2,
|
||||
0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA,
|
||||
0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
|
||||
0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8,
|
||||
0xD9, 0xDA, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6,
|
||||
0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
|
||||
0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA,
|
||||
0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00,
|
||||
];
|
||||
|
||||
final pixelData = List.filled(width * height * 3, 0x80);
|
||||
final jpegTail = [0xFF, 0xD9];
|
||||
|
||||
return Uint8List.fromList([...jpegHeader, ...pixelData, ...jpegTail]);
|
||||
}
|
||||
|
||||
Future<void> _saveTestFile(Uint8List bytes, String fileName) async {
|
||||
try {
|
||||
final file = File(fileName);
|
||||
await file.writeAsBytes(bytes);
|
||||
print(' 💾 已保存: ${file.absolute.path}');
|
||||
} catch (e) {
|
||||
print(' ⚠️ 无法保存文件: $e');
|
||||
}
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
// 2026-04-20 | test_what_to_eat.dart | 今天吃什么完整接口测试 | 验证动态筛选全流程
|
||||
// 运行: dart run scripts/test_what_to_eat.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:io';
|
||||
|
||||
const String baseUrl = 'https://eat.wktyl.com/api';
|
||||
|
||||
int passCount = 0;
|
||||
int failCount = 0;
|
||||
int warnCount = 0;
|
||||
|
||||
void pass(String msg) {
|
||||
passCount++;
|
||||
print(' ✅ $msg');
|
||||
}
|
||||
|
||||
void fail(String msg) {
|
||||
failCount++;
|
||||
print(' ❌ $msg');
|
||||
}
|
||||
|
||||
void warn(String msg) {
|
||||
warnCount++;
|
||||
print(' ⚠️ $msg');
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
print('╔══════════════════════════════════════════╗');
|
||||
print('║ 🍽️ 今天吃什么 - 动态筛选接口测试 ║');
|
||||
print('╚══════════════════════════════════════════╝\n');
|
||||
|
||||
// ─── 1. api_filter: 菜谱大类 ───
|
||||
print('━━━ 1. api_filter: 获取菜谱大类 ━━━');
|
||||
final mainCatsData = await getJson('api_filter.php', {'act': 'recipe_main_categories'});
|
||||
List<Map> mainCats = [];
|
||||
if (mainCatsData != null && mainCatsData['code'] == 200) {
|
||||
mainCats = (mainCatsData['data']?['list'] as List? ?? []).cast<Map>();
|
||||
print(' 大类数量: ${mainCats.length}');
|
||||
for (final c in mainCats) {
|
||||
print(' 📂 ${c['name']} (ID:${c['id']}): ${c['recipe_count']}道');
|
||||
}
|
||||
if (mainCats.isNotEmpty) {
|
||||
pass('菜谱大类加载成功 (${mainCats.length}个)');
|
||||
} else {
|
||||
fail('菜谱大类为空');
|
||||
}
|
||||
} else {
|
||||
fail('菜谱大类请求失败');
|
||||
}
|
||||
|
||||
// ─── 2. api_filter: 子类 ───
|
||||
print('\n━━━ 2. api_filter: 获取子分类 ━━━');
|
||||
int? testParentId;
|
||||
List<Map> subCats = [];
|
||||
if (mainCats.isNotEmpty) {
|
||||
testParentId = mainCats.first['id'] as int;
|
||||
final subData = await getJson('api_filter.php', {
|
||||
'act': 'recipe_sub_categories',
|
||||
'parent_id': '$testParentId',
|
||||
});
|
||||
if (subData != null && subData['code'] == 200) {
|
||||
subCats = (subData['data']?['list'] as List? ?? []).cast<Map>();
|
||||
print(' 父类ID=$testParentId 的子类数量: ${subCats.length}');
|
||||
for (final c in subCats.take(5)) {
|
||||
print(' 📂 ${c['name']} (ID:${c['id']}): ${c['recipe_count']}道');
|
||||
}
|
||||
if (subCats.isNotEmpty) {
|
||||
pass('子分类加载成功 (${subCats.length}个)');
|
||||
} else {
|
||||
warn('子分类为空,可能该大类无子分类');
|
||||
}
|
||||
} else {
|
||||
fail('子分类请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无大类数据,跳过子分类测试');
|
||||
}
|
||||
|
||||
// ─── 3. filter_steps: 无筛选条件 ───
|
||||
print('\n━━━ 3. filter_steps: 无筛选条件(基线) ━━━');
|
||||
final fs0 = await fetchFilterSteps();
|
||||
int baseMatched = 0;
|
||||
int baseTagCount = 0;
|
||||
if (fs0 != null) {
|
||||
baseMatched = fs0['matched_count'] ?? 0;
|
||||
final tags = (fs0['available_tags'] as List? ?? []);
|
||||
baseTagCount = tags.length;
|
||||
print(' 匹配菜谱数: $baseMatched');
|
||||
print(' 可用标签数: $baseTagCount');
|
||||
for (final t in tags.take(5)) {
|
||||
final tm = t as Map;
|
||||
print(' 🏷️ ${tm['name']}: ${tm['count']}道');
|
||||
}
|
||||
if (baseMatched > 0) {
|
||||
pass('无筛选基线: $baseMatched 道菜谱');
|
||||
} else {
|
||||
fail('无筛选基线: 匹配数为0');
|
||||
}
|
||||
if (baseTagCount > 0) {
|
||||
pass('无筛选基线: $baseTagCount 个标签');
|
||||
} else {
|
||||
warn('无筛选基线: 标签为空');
|
||||
}
|
||||
} else {
|
||||
fail('filter_steps 无筛选请求失败');
|
||||
}
|
||||
|
||||
// ─── 4. filter_steps: 选1个分类 ───
|
||||
print('\n━━━ 4. filter_steps: 选1个分类 ━━━');
|
||||
int cat1Matched = 0;
|
||||
int cat1TagCount = 0;
|
||||
List<Map> cat1Tags = [];
|
||||
if (testParentId != null) {
|
||||
final fs1 = await fetchFilterSteps(categories: [testParentId]);
|
||||
if (fs1 != null) {
|
||||
cat1Matched = fs1['matched_count'] ?? 0;
|
||||
cat1Tags = ((fs1['available_tags'] as List? ?? [])).cast<Map>();
|
||||
cat1TagCount = cat1Tags.length;
|
||||
print(' 选分类[$testParentId]: 匹配 $cat1Matched 道');
|
||||
print(' 可用标签数: $cat1TagCount');
|
||||
for (final t in cat1Tags.take(5)) {
|
||||
print(' 🏷️ ${t['name']}: ${t['count']}道');
|
||||
}
|
||||
if (cat1Matched <= baseMatched) {
|
||||
pass('选1分类后匹配数($cat1Matched) <= 基线($baseMatched)');
|
||||
} else {
|
||||
fail('选1分类后匹配数($cat1Matched) > 基线($baseMatched),逻辑异常');
|
||||
}
|
||||
if (cat1TagCount <= baseTagCount) {
|
||||
pass('选1分类后标签数($cat1TagCount) <= 基线($baseTagCount)');
|
||||
} else {
|
||||
warn('选1分类后标签数($cat1TagCount) > 基线($baseTagCount)');
|
||||
}
|
||||
} else {
|
||||
fail('filter_steps 选1分类请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无分类ID,跳过');
|
||||
}
|
||||
|
||||
// ─── 5. filter_steps: 选分类+标签 ───
|
||||
print('\n━━━ 5. filter_steps: 选分类+标签 ━━━');
|
||||
int catTagMatched = 0;
|
||||
if (testParentId != null && cat1Tags.isNotEmpty) {
|
||||
final firstTagId = cat1Tags.first['id'];
|
||||
final firstTagName = cat1Tags.first['name'];
|
||||
final fs2 = await fetchFilterSteps(categories: [testParentId], tags: [firstTagId]);
|
||||
if (fs2 != null) {
|
||||
catTagMatched = fs2['matched_count'] ?? 0;
|
||||
final tags2 = (fs2['available_tags'] as List? ?? []);
|
||||
print(' 选分类[$testParentId]+标签[$firstTagName($firstTagId)]: 匹配 $catTagMatched 道');
|
||||
print(' 可用标签数: ${tags2.length}');
|
||||
for (final t in tags2.take(5)) {
|
||||
final tm = t as Map;
|
||||
print(' 🏷️ ${tm['name']}: ${tm['count']}道');
|
||||
}
|
||||
if (catTagMatched <= cat1Matched) {
|
||||
pass('分类+标签匹配数($catTagMatched) <= 仅分类($cat1Matched)');
|
||||
} else {
|
||||
fail('分类+标签匹配数($catTagMatched) > 仅分类($cat1Matched),逻辑异常');
|
||||
}
|
||||
} else {
|
||||
fail('filter_steps 分类+标签请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无标签数据,跳过');
|
||||
}
|
||||
|
||||
// ─── 6. filter_steps: 多分类组合 ───
|
||||
print('\n━━━ 6. filter_steps: 多分类组合 ━━━');
|
||||
if (mainCats.length >= 2 && testParentId != null) {
|
||||
final secondCatId = mainCats[1]['id'] as int;
|
||||
final secondCatName = mainCats[1]['name'];
|
||||
final fs3 = await fetchFilterSteps(categories: [testParentId, secondCatId]);
|
||||
if (fs3 != null) {
|
||||
final multiMatched = fs3['matched_count'] ?? 0;
|
||||
print(' 选分类[$testParentId,$secondCatId($secondCatName)]: 匹配 $multiMatched 道');
|
||||
print(' 对比: 仅1分类=$cat1Matched, 2分类=$multiMatched');
|
||||
if (multiMatched >= cat1Matched) {
|
||||
pass('多分类匹配数($multiMatched) >= 单分类($cat1Matched) [OR逻辑正确]');
|
||||
} else {
|
||||
warn('多分类匹配数($multiMatched) < 单分类($cat1Matched),可能子分类重叠');
|
||||
}
|
||||
} else {
|
||||
fail('filter_steps 多分类请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('大类不足2个,跳过多分类测试');
|
||||
}
|
||||
|
||||
// ─── 7. filter_steps: 子分类筛选 ───
|
||||
print('\n━━━ 7. filter_steps: 子分类筛选 ━━━');
|
||||
if (subCats.isNotEmpty) {
|
||||
final subCatId = subCats.first['id'] as int;
|
||||
final subCatName = subCats.first['name'];
|
||||
final fs4 = await fetchFilterSteps(categories: [subCatId]);
|
||||
if (fs4 != null) {
|
||||
final subMatched = fs4['matched_count'] ?? 0;
|
||||
print(' 选子分类[$subCatName($subCatId)]: 匹配 $subMatched 道');
|
||||
if (subMatched <= cat1Matched && subMatched > 0) {
|
||||
pass('子分类匹配数($subMatched) <= 父分类($cat1Matched) 且 > 0');
|
||||
} else if (subMatched == 0) {
|
||||
warn('子分类匹配数为0,可能无直接关联菜谱');
|
||||
} else {
|
||||
warn('子分类匹配数($subMatched) > 父分类($cat1Matched)');
|
||||
}
|
||||
} else {
|
||||
fail('filter_steps 子分类请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无子分类数据,跳过');
|
||||
}
|
||||
|
||||
// ─── 8. filter_apply: 获取推荐菜谱 ───
|
||||
print('\n━━━ 8. filter_apply: 获取推荐菜谱 ━━━');
|
||||
if (testParentId != null) {
|
||||
final applyResult = await fetchFilterApply(categories: [testParentId], count: 3);
|
||||
if (applyResult != null) {
|
||||
final recipes = (applyResult['recipes'] as List? ?? []);
|
||||
final total = applyResult['total_matched'] ?? 0;
|
||||
print(' 返回菜谱数: ${recipes.length}');
|
||||
print(' 总匹配数: $total');
|
||||
for (final r in recipes) {
|
||||
final rm = r as Map;
|
||||
print(' 🍳 ${rm['title']} (ID: ${rm['id']})');
|
||||
}
|
||||
if (recipes.isNotEmpty) {
|
||||
pass('filter_apply 返回 ${recipes.length} 道菜谱');
|
||||
} else {
|
||||
warn('filter_apply 返回空列表');
|
||||
}
|
||||
if (total == cat1Matched) {
|
||||
pass('filter_apply 总匹配数($total) = filter_steps 匹配数($cat1Matched)');
|
||||
} else {
|
||||
warn('filter_apply 总匹配数($total) != filter_steps 匹配数($cat1Matched)');
|
||||
}
|
||||
} else {
|
||||
fail('filter_apply 请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无分类ID,跳过');
|
||||
}
|
||||
|
||||
// ─── 9. filter_apply: 分类+标签组合 ───
|
||||
print('\n━━━ 9. filter_apply: 分类+标签组合 ━━━');
|
||||
if (testParentId != null && cat1Tags.isNotEmpty) {
|
||||
final tagId = cat1Tags.first['id'];
|
||||
final tagName = cat1Tags.first['name'];
|
||||
final applyResult2 = await fetchFilterApply(
|
||||
categories: [testParentId],
|
||||
tags: [tagId],
|
||||
count: 3,
|
||||
);
|
||||
if (applyResult2 != null) {
|
||||
final recipes = (applyResult2['recipes'] as List? ?? []);
|
||||
final total = applyResult2['total_matched'] ?? 0;
|
||||
print(' 分类[$testParentId]+标签[$tagName]: 返回 ${recipes.length} 道菜谱');
|
||||
print(' 总匹配数: $total');
|
||||
for (final r in recipes) {
|
||||
final rm = r as Map;
|
||||
print(' 🍳 ${rm['title']} (ID: ${rm['id']})');
|
||||
}
|
||||
if (total <= cat1Matched) {
|
||||
pass('分类+标签总匹配($total) <= 仅分类($cat1Matched)');
|
||||
} else {
|
||||
fail('分类+标签总匹配($total) > 仅分类($cat1Matched),逻辑异常');
|
||||
}
|
||||
} else {
|
||||
fail('filter_apply 分类+标签请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无标签数据,跳过');
|
||||
}
|
||||
|
||||
// ─── 10. 动态筛选核心验证 ───
|
||||
print('\n━━━ 10. 动态筛选核心验证 ━━━');
|
||||
print(' 基线(无筛选): $baseMatched 道');
|
||||
print(' 选1分类: $cat1Matched 道');
|
||||
print(' 分类+标签: $catTagMatched 道');
|
||||
|
||||
if (cat1Matched > 0 && cat1Matched < baseMatched && catTagMatched > 0 && catTagMatched < cat1Matched) {
|
||||
pass('🎯 动态筛选核心逻辑正确:选项越多,匹配越少');
|
||||
} else if (cat1Matched > 0 && cat1Matched <= baseMatched && catTagMatched >= 0 && catTagMatched <= cat1Matched) {
|
||||
warn('动态筛选部分正常(可能标签筛选后匹配数不变)');
|
||||
} else {
|
||||
fail('动态筛选逻辑异常');
|
||||
}
|
||||
|
||||
// ─── 11. 标签count准确性抽检 ───
|
||||
print('\n━━━ 11. 标签count准确性抽检 ━━━');
|
||||
if (cat1Tags.isNotEmpty && testParentId != null) {
|
||||
final pid = testParentId;
|
||||
final sampleTag = cat1Tags[Random().nextInt(cat1Tags.length.clamp(0, cat1Tags.length - 1))];
|
||||
final sampleTagId = sampleTag['id'] as int;
|
||||
final sampleTagName = sampleTag['name'];
|
||||
final sampleTagCount = sampleTag['count'] ?? 0;
|
||||
print(' 抽检标签: $sampleTagName (ID:$sampleTagId, 声称:$sampleTagCount道)');
|
||||
|
||||
final verifyResult = await fetchFilterSteps(categories: [pid], tags: [sampleTagId]);
|
||||
if (verifyResult != null) {
|
||||
final verifyMatched = verifyResult['matched_count'] ?? 0;
|
||||
print(' 实际匹配: $verifyMatched 道');
|
||||
if (verifyMatched == sampleTagCount) {
|
||||
pass('标签count准确: $sampleTagCount == $verifyMatched');
|
||||
} else if ((verifyMatched - sampleTagCount).abs() <= 2) {
|
||||
warn('标签count近似: 声称$sampleTagCount vs 实际$verifyMatched (差${(verifyMatched - sampleTagCount).abs()})');
|
||||
} else {
|
||||
fail('标签count偏差大: 声称$sampleTagCount vs 实际$verifyMatched (差${(verifyMatched - sampleTagCount).abs()})');
|
||||
}
|
||||
} else {
|
||||
fail('抽检验证请求失败');
|
||||
}
|
||||
} else {
|
||||
warn('无标签数据,跳过抽检');
|
||||
}
|
||||
|
||||
// ─── 汇总 ───
|
||||
print('\n╔══════════════════════════════════════════╗');
|
||||
print('║ 📊 测试结果汇总 ║');
|
||||
print('╠══════════════════════════════════════════╣');
|
||||
print('║ ✅ 通过: $passCount ║');
|
||||
print('║ ⚠️ 警告: $warnCount ║');
|
||||
print('║ ❌ 失败: $failCount ║');
|
||||
print('╚══════════════════════════════════════════╝');
|
||||
|
||||
if (failCount == 0) {
|
||||
print('\n🎉 所有核心测试通过!动态筛选功能正常。');
|
||||
} else {
|
||||
print('\n⚠️ 存在 $failCount 个失败项,请检查接口逻辑。');
|
||||
}
|
||||
|
||||
exit(failCount > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getJson(String endpoint, Map<String, String> params) async {
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/$endpoint').replace(queryParameters: params);
|
||||
final client = HttpClient();
|
||||
client.connectionTimeout = const Duration(seconds: 15);
|
||||
final request = await client.getUrl(uri);
|
||||
final response = await request.close();
|
||||
final body = await response.transform(utf8.decoder).join();
|
||||
client.close();
|
||||
return json.decode(body) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print(' ❌ 请求失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> fetchFilterSteps({
|
||||
List<int>? categories,
|
||||
List<int>? tags,
|
||||
}) async {
|
||||
final params = <String, String>{'act': 'filter_steps'};
|
||||
if (categories != null && categories.isNotEmpty) {
|
||||
params['category'] = categories.join(',');
|
||||
}
|
||||
if (tags != null && tags.isNotEmpty) {
|
||||
params['tag'] = tags.join(',');
|
||||
}
|
||||
final data = await getJson('api_what_to_eat.php', params);
|
||||
if (data != null && data['code'] == 200) {
|
||||
return data['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
if (data != null && data['code'] != 200) {
|
||||
print(' ❌ 接口返回错误: code=${data['code']}, msg=${data['message']}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> fetchFilterApply({
|
||||
List<int>? categories,
|
||||
List<int>? tags,
|
||||
int count = 5,
|
||||
}) async {
|
||||
final params = <String, String>{'act': 'filter_apply', 'count': '$count'};
|
||||
if (categories != null && categories.isNotEmpty) {
|
||||
params['category'] = categories.join(',');
|
||||
}
|
||||
if (tags != null && tags.isNotEmpty) {
|
||||
params['tag'] = tags.join(',');
|
||||
}
|
||||
final data = await getJson('api_what_to_eat.php', params);
|
||||
if (data != null && data['code'] == 200) {
|
||||
return data['data'] as Map<String, dynamic>?;
|
||||
}
|
||||
if (data != null && data['code'] != 200) {
|
||||
print(' ❌ 接口返回错误: code=${data['code']}, msg=${data['message']}');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user