<?php
/** Additional real consumer chain, isolated SQLite only; no business HTTP/AI/device calls. */
$root='/Users/wiscourper/Documents/workspace/zhct/zhctproject/store';
ob_start();require $root.'/vendor/bin/phpunit';ob_end_clean();
require $root.'/tests/bootstrap.php';
require $root.'/tests/Tchef/DishSyncDatabaseTest.php';
set_exception_handler(function(Throwable $e){fwrite(STDERR,json_encode(['status'=>'failed','error'=>$e->getMessage(),'trace'=>$e->getTraceAsString()],JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT).PHP_EOL);exit(1);});
function invokeFixture($object,$method,...$args){$m=new ReflectionMethod($object,$method);$m->setAccessible(true);return $m->invokeArgs($object,$args);}
$fixture=new \tests\Tchef\DishSyncDatabaseTest();
invokeFixture($fixture,'setUp');
register_shutdown_function(function()use($fixture){invokeFixture($fixture,'tearDown');});
$db=\think\Config::get('database');
if($db['type']!=='sqlite'||strpos(realpath($db['database']),realpath(sys_get_temp_dir()).DIRECTORY_SEPARATOR)!==0)throw new RuntimeException('Isolation guard failed');
$pdo=new PDO('sqlite:'.$db['database']);$pdo->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);
foreach(['en_name TEXT','file_path TEXT DEFAULT ""','unit_number TEXT DEFAULT 1'] as $column)$pdo->exec('ALTER TABLE ydy_dishes ADD '.$column);
$pdo->exec('CREATE TABLE ydy_date_menu(id INTEGER PRIMARY KEY AUTOINCREMENT,staff_uuid TEXT,menu_date TEXT,meal_times INTEGER,meal_type INTEGER,dishes_uuid TEXT,weight TEXT)');
$pdo->exec('CREATE TABLE ydy_recipes(id INTEGER PRIMARY KEY AUTOINCREMENT,dishes_uuid TEXT,menu_date TEXT,meal_times INTEGER)');
$checks=[];
function demand($condition,string $label):void{global $checks;if(!$condition)throw new RuntimeException($label);$checks[]=$label;}
function normalized(int $id):array{
 $fields=array_column(\app\common\enum\dishes\Nutrient::data(),'field');$fields=array_merge($fields,['weight','raw_flag','raw_flag_weight']);
 $dish=\think\Db::name('dishes')->where('id',$id)->find();$nutrition=[];foreach($fields as $key)$nutrition[$key]=(float)$dish[$key];
 $rows=\think\Db::name('dish')->where(['dishes_id'=>$id,'is_delete'=>0])->order('foods_id')->select();$foods=[];
 foreach($rows as $row){$out=[];foreach(['foods_id','composition_id','foods_weight','food_weight','foods_ratio','composition_ratio','edible_part','ssb','raw_flag_weight','dishes_ssb','gia','gib','raw_flag','cook_method','category','minute_sort'] as $k)$out[$k]=round((float)$row[$k],6);$foods[]=$out;}
 $tags=\think\Db::name('dishes_tag')->where('dishes_id',$id)->field('nutrient,level,name,color')->order('nutrient,level,name')->select();
 return ['nutrition'=>$nutrition,'foods'=>$foods,'tags'=>$tags];
}
function manualSave(string $name,array $weights,?int $id=null):int {
 $sum=array_sum($weights);$foods=[];
 foreach($weights as $foodId=>$weight){$f=\think\Db::name('foods_composition')->where('id',$foodId)->find();$foods[]=['foods_id'=>$foodId,'foods_name'=>$f['name'],'foods_weight'=>$weight,'foods_ratio'=>$weight/$sum*100,'cook_method'=>1,'category'=>$f['category'],'minute_sort'=>$f['minute_sort'],'raw_flag'=>1];}
 $data=['name'=>$name,'category'=>1,'category_one'=>1,'category_two'=>2,'sub_category'=>2,'unit'=>2,'unit_number'=>1,'price'=>10,'cook_method'=>1,'raw_flag'=>1,'raw_flag_weight'=>0,'weight'=>$sum,'unsuitable'=>[],'foods'=>$foods];
 if($id===null){$m=new \app\store\model\dishes\Dishes();demand($m->add($data,'fixture-manual','test')===true,'real manual model add succeeds');return (int)$m['id'];}
 // edit calls syncAi, but the isolated fixture has no ai_sync_switch so it exits before HTTP.
 $m=\app\store\model\dishes\Dishes::detail($id);demand($m->edit($data,'fixture-manual')===true,'real manual model edit succeeds');return $id;
}
$remote=invokeFixture($fixture,'remote');
demand(invokeFixture($fixture,'apply',$remote)==='created','new remote dish imports');
$synced=\think\Db::name('dishes')->where('name',$remote['dishName'])->find();$syncId=(int)$synced['id'];$syncUuid=$synced['uuid'];
$manualId=manualSave('人工基线',[10=>300,20=>20]);$manualUuid=\think\Db::name('dishes')->where('id',$manualId)->value('uuid');
demand(normalized($syncId)===normalized($manualId),'all 37 nutrients GI GL tags recipe helper fields equal real manual save');
$api=(new ReflectionClass(\app\p\logic\Api::class))->newInstanceWithoutConstructor();
$targets=$api->recommendWeight(2000);demand($targets['gushu']===250,'real Api recommendWeight(2000) gives 250g staple target');
$consumer=new \app\common\service\nutrition\DishRecommendWeightService();
$date=date('Y-m-d');$recommended=$consumer->calculate('fixture-user',2,[$syncUuid,$manualUuid],$targets,$date);
demand($recommended[$syncUuid]===$recommended[$manualUuid]&&$recommended[$syncUuid]===200,'real DB recommendation gives same 200g pickup before consumption');
\think\Db::name('date_menu')->insert(['staff_uuid'=>'fixture-user','menu_date'=>$date,'meal_times'=>2,'meal_type'=>3,'dishes_uuid'=>$syncUuid,'weight'=>50]);
$after=$consumer->calculate('fixture-user',2,[$syncUuid,$manualUuid],$targets,$date);
demand($after[$syncUuid]===$after[$manualUuid]&&$after[$syncUuid]===150,'real DB recommendation subtracts 50g consumed and gives 150g');
foreach([$syncUuid,$manualUuid] as $uuid)\think\Db::name('recipes')->insert(['dishes_uuid'=>$uuid,'menu_date'=>$date,'meal_times'=>2]);
$top=(new \app\m\logic\Dishes())->top5('energy');demand($top['code']===0,'real m Dishes top5 energy query succeeds');
$topMap=array_column($top['data'],null,'uuid');
demand(isset($topMap[$syncUuid],$topMap[$manualUuid]),'top5 contains both equivalent scheduled dishes');
demand((float)$topMap[$syncUuid]['energy']===(float)$topMap[$manualUuid]['energy']&&abs((float)$topMap[$syncUuid]['energy']-147.37)<0.001,'real top5 every100g energy equals 147.37 for both');
$snapshotCount=\think\Db::name('tchef_dish_snapshot')->count();
demand(invokeFixture($fixture,'apply',$remote)==='skipped','same remote content skips');
demand($snapshotCount===\think\Db::name('tchef_dish_snapshot')->count(),'unchanged remote does not add snapshots');
\think\Db::name('dishes')->where('id',$syncId)->update(['price'=>27,'unit_number'=>2]);
$remote['programList'][0]['materialList'][0]['weight']=300;$remote['updateTime']='2026-01-03 09:10:11';
demand(invokeFixture($fixture,'apply',$remote)==='updated','changed recipe updates original dish');
manualSave('人工基线',[10=>400,20=>20],$manualId);
demand(normalized($syncId)===normalized($manualId),'updated full nutrition and tags equal real manual edit');
$updated=\think\Db::name('dishes')->where('id',$syncId)->find();
demand((float)$updated['price']===27.0&&(float)$updated['unit_number']===2.0,'local price and unit quantity preserved');
demand($updated['uuid']===$syncUuid&&(int)$updated['category_one']===1&&(int)$updated['category_two']===2,'identity and base classification preserved');
demand($updated['create_time']==='2026-01-01 00:00:00'&&$updated['update_time']==='2026-01-03 09:10:11','remote business times survive real nutrition recalculation');
$updatedWeights=$consumer->calculate('fixture-user',2,[$syncUuid,$manualUuid],$targets,$date);
demand($updatedWeights[$syncUuid]===$updatedWeights[$manualUuid],'updated remote recipe recommendation equals manual edit with same meal history');
$valid=normalized($syncId);$broken=$remote;$broken['dishStatus']=3;$broken['programList'][0]['materialList'][0]['materialId']='unknown';
try{invokeFixture($fixture,'apply',$broken);throw new RuntimeException('Broken recipe unexpectedly succeeded');}catch(\app\common\service\tchef\BlockedException $e){$checks[]='missing food blocks bad recipe';}
demand(normalized($syncId)===$valid,'bad recipe preserves previous valid nutrition and ingredients');
demand((int)\think\Db::name('dishes')->where('id',$syncId)->value('status')===0,'verified remote stopped status disables local despite bad recipe');
$disabledWeights=$consumer->calculate('fixture-user',2,[$syncUuid],$targets,$date);
$cater=(new ReflectionClass(\app\p\logic\Cater::class))->newInstanceWithoutConstructor();
$caterModel=new ReflectionProperty($cater,'CaterModel');$caterModel->setAccessible(true);$caterModel->setValue($cater,new \app\model\Cater());
$search=$cater->search('');$searchIds=array_column($search['data']['list'],'uuid');
demand(!in_array($syncUuid,$searchIds,true)&&in_array($manualUuid,$searchIds,true),'real Cater search excludes remote-disabled dish but includes enabled manual baseline');
\think\Db::name('dishes')->where('id',$manualId)->update(['status'=>0]);
$search=$cater->search('');demand(count($search['data']['list'])===0,'manual and remote disabled dishes equally excluded by actual effective candidate search');
$disabledTop=(new \app\m\logic\Dishes())->top5('energy');$disabledTopIds=array_column($disabledTop['data'],'uuid');
demand(in_array($syncUuid,$disabledTopIds,true)&&in_array($manualUuid,$disabledTopIds,true),'legacy top5 includes both disabled scheduled dishes equally; baseline behavior recorded');

$remote['isDel']=1;demand(invokeFixture($fixture,'apply',$remote)==='deleted','explicit deletion soft deletes mapped local');
demand((int)\think\Db::name('dishes')->where('id',$syncId)->value('is_delete')===1,'local deletion marker set');
demand(\think\Db::name('dish')->where(['dishes_id'=>$syncId,'is_delete'=>0])->count()===0,'deleted recipe removed from active ingredients');
$postDelete=$consumer->calculate('fixture-user',2,[$syncUuid],$targets,$date);
$search=$cater->search('');demand(!in_array($syncUuid,array_column($search['data']['list'],'uuid'),true),'real Cater search excludes explicitly deleted synced dish');
$deletedTop=(new \app\m\logic\Dishes())->top5('energy');$deletedTopIds=array_column($deletedTop['data'],'uuid');
demand(in_array($syncUuid,$deletedTopIds,true),'legacy top5 still contains historical scheduled deleted UUID; does not claim filter guarantee');
\think\Db::name('dishes')->where('id',$manualId)->update(['is_del'=>0,'is_delete'=>1]);
$bothDeletedTop=(new \app\m\logic\Dishes())->top5('energy');$bothDeletedIds=array_column($bothDeletedTop['data'],'uuid');
demand(in_array($syncUuid,$bothDeletedIds,true)&&in_array($manualUuid,$bothDeletedIds,true),'legacy top5 handles manual and synchronized deleted markers identically');

demand($postDelete[$syncUuid]===0,'real DB pickup recommendation returns zero for deleted dish without active recipe');
demand(\think\Db::name('date_menu')->where('dishes_uuid',$syncUuid)->count()===1,'historical meal record retained');
echo json_encode(['status'=>'passed','checks'=>count($checks),'passed_checks'=>$checks,'real_call_chain'=>['DishSync::apply','Dishes::add/edit (manual baseline)','Dishes::calculateDishesData','Api::recommendWeight','DishRecommendWeightService::calculate','m\\logic\\Dishes::top5'],'recommendation'=>['target_energy'=>2000,'initial_grams'=>$recommended[$syncUuid],'after_50g_consumption'=>$after[$syncUuid],'updated_grams'=>$updatedWeights[$syncUuid],'disabled_direct_service_grams'=>$disabledWeights[$syncUuid],'deleted_grams'=>$postDelete[$syncUuid]],'limits'=>['SQLite numeric TEXT fixture avoids legacy PDO integer binding; not deployment DDL','No real HTTP/AI/device/image call','Invokes real model save and recommendation consumers, not complete authenticated HTTP controllers or UI','Direct recommendation calculator accepts explicitly provided disabled UUIDs; candidate filtering remains caller responsibility, not proven by this fixture','Does not redefine existing candidate filtering or recompute historical meal nutrition policy']],JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE).PHP_EOL;
