Enhance Beryl Agent detail and report views
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { expect,it } from "vitest";
|
||||
import { decodeCreatives,evaluateFatigue,type Creative } from "../src/fatigue.js";
|
||||
import {defaults,settingsSchema} from "../src/monitor.js";
|
||||
const creative:Creative={id:"video",campaignId:"c",kind:"item",variant:"ADS_AND_ORGANIC",points:Array.from({length:10},(_,i)=>({date:`2026-09-${String(i+1).padStart(2,"0")}`,impressions:1000,clicks:i<7?100:50,plays:1000,views:i<7?500:200}))};
|
||||
it("requires corroborating viewing decline, not CTR alone",()=>{
|
||||
const r=evaluateFatigue(creative,"2026-09-08",defaults,true);
|
||||
expect(r.state).toBe("suspected_fatigue");expect(r.ctrDrop).toBe(50);expect(r.baseline.ctr).toBe(.1);
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,plays:0,views:0}))},"2026-09-08",defaults,true).state).toBe("creative_decline");
|
||||
});
|
||||
it("fails closed on stale, pending, sparse and missing baseline",()=>{
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,false).state).toBe("data_issue");
|
||||
expect(evaluateFatigue(creative,"2026-09-08",defaults,true,"配置修改中").state).toBe("data_issue");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.slice(7)},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
expect(evaluateFatigue({...creative,points:creative.points.map(p=>({...p,impressions:100,clicks:1}))},"2026-09-08",defaults,true).state).toBe("observe");
|
||||
});
|
||||
it("uses weighted rates and allows zero recent clicks to trigger decline",()=>{
|
||||
const r=evaluateFatigue({...creative,points:creative.points.map((p,i)=>({...p,impressions:i===0?10000:1000,clicks:i<7?100:0}))},"2026-09-08",defaults,true);
|
||||
expect(r.baseline.ctr).toBe(700/16000);expect(r.ctrDrop).toBe(100);
|
||||
});
|
||||
it("migrates old settings and rejects invalid thresholds",()=>{
|
||||
expect(settingsSchema.parse({enabled:false,minSpend:100,minOrders:5,minImpressions:1000,staleHours:12}).fatigueCtrDrop).toBe(25);
|
||||
expect(()=>settingsSchema.parse({...defaults,fatigueCtrDrop:101})).toThrow();
|
||||
});
|
||||
it("validates scope, duplicates, dates and truncation; excludes product cards",()=>{
|
||||
const raw={columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["c","v","item","ADS","2026-09-01",100,5,0,0],["c","p","product_card","ADS","2026-09-01",100,5,0,0]],account_context:{external_account_id:"a",tenant_id:"t"},query_meta:{truncated:false}};
|
||||
const decode=(r:typeof raw)=>decodeCreatives(r,"a","t","2026-09-01","2026-09-10");
|
||||
expect(decode(raw).map.size).toBe(1);expect(decode(raw).excluded).toBe(1);
|
||||
expect(()=>decode({...raw,rows:[raw.rows[0]!,raw.rows[0]!]})).toThrow("重复");
|
||||
expect(()=>decode({...raw,query_meta:{truncated:true}})).toThrow("截断");
|
||||
expect(()=>decode({...raw,account_context:{external_account_id:"b",tenant_id:"t"}})).toThrow("账户");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { it,expect } from "vitest";
|
||||
import { evaluate,defaults,decodeMetrics,AdsMonitor,type Campaign } from "../src/monitor.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
const now=Date.parse("2026-09-09T08:00:00Z");
|
||||
const base:Campaign={id:"1",name:"test",target:2,active:true,synced:"2026-09-09T07:00:00Z",started:"2026-09-01T00:00:00Z",pending:false,budget:100,metrics:{spend:100,revenue:300,orders:10,impressions:2000,clicks:100},previous:{spend:100,revenue:250,orders:10,impressions:2000,clicks:100}};
|
||||
it("uses each campaign target, never global 2.5",()=>{expect(evaluate(base,defaults,now,true).state).toBe("potential");expect(evaluate({...base,target:5},defaults,now,true).state).toBe("under_target");expect(evaluate({...base,target:null},defaults,now,true).state).toBe("target_missing");});
|
||||
it("does not confuse missing, stale or small data with loss",()=>{expect(evaluate({...base,metrics:undefined},defaults,now,true).state).toBe("data_issue");expect(evaluate(base,defaults,now,false).state).toBe("data_issue");expect(evaluate({...base,metrics:{...base.metrics!,spend:10}},defaults,now,true).state).toBe("observe");expect(evaluate({...base,pending:true},defaults,now,true).state).toBe("data_issue");});
|
||||
it("calculates ratios from totals and handles zero orders",()=>{const r=evaluate(base,defaults,now,true);expect(r.roi).toBe(3);expect(r.cpa).toBe(10);expect(r.ctr).toBe(.05);expect(evaluate({...base,metrics:{...base.metrics!,orders:0,revenue:0}},defaults,now,true).state).toBe("no_orders");});
|
||||
it("blocks truncated metrics",()=>{expect(()=>decodeMetrics({columns:[],rows:[],query_meta:{truncated:true}})).toThrow("截断");});
|
||||
it("isolates all TikTok accounts, restores selection, persists and deduplicates",async()=>{
|
||||
const dir=await mkdtemp(path.join(tmpdir(),"ads-monitor-test-"));
|
||||
let id="account-1";
|
||||
let fail=false;
|
||||
const account=(external_account_id:string)=>({external_account_id,account_name:external_account_id,tenant_id:"tenant",platform:"tiktok",reporting_timezone:"Asia/Shanghai",reporting_utc_offset_minutes:480,account_currency:external_account_id==="account-1"?"CNY":"USD"});
|
||||
const read=async(name:string,args:Record<string,unknown>={})=>{
|
||||
if(name==="swads_whoami")return {access:{org_id:"org",current_account:account(id),accounts:[account("account-1"),account("account-2"),{...account("other"),platform:"qianchuan"}]}};
|
||||
if(name==="campaign_list"){
|
||||
id=String(args.external_account_id);
|
||||
if(fail&&id==="account-2")throw new Error("账户权限不足");
|
||||
return {total:2,account_context:{external_account_id:id,tenant_id:"tenant"},items:[{subject_type:"minis",external_campaign_id:null,external_account_id:null},{subject_type:"commerce_shop",external_campaign_id:"1",external_account_id:id,campaign_name:"test",operation_status:"ENABLE",last_synced_at:new Date().toISOString(),commerce_summary:{target_roi:id==="account-1"?2:5,schedule_start_at:"2020-01-01"}}]};
|
||||
}
|
||||
const query=args.query as {query_kind?:string;date_from?:string}|undefined;
|
||||
if(query?.query_kind==="creative_breakdown") {
|
||||
const recent=query.date_from!>=new Date(Date.now()+480*60000-3*86400000).toISOString().slice(0,10);
|
||||
return {columns:["external_campaign_id","external_creative_id","creative_identity_kind","creative_variant","date","product_impressions","product_clicks","video_play_actions","video_watched_6s"].map(name=>({name})),rows:[["1","v","item","ADS_AND_ORGANIC",query.date_from,1000,recent?50:100,0,0]],account_context:{external_account_id:id,tenant_id:"tenant"},query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
}
|
||||
return {columns:["external_campaign_id","spend","gross_revenue","orders","product_impressions","product_clicks"].map(name=>({name})),rows:[["1",100,300,10,2000,100]],query_meta:{truncated:false,attribution_resolutions:[{status:"available",freshness:new Date().toISOString()}]}};
|
||||
};
|
||||
const m=new AdsMonitor(dir,read);
|
||||
const restored=new AdsMonitor(dir,read);
|
||||
try{
|
||||
await m.init();expect((await m.check()).newAlerts).toBe(4);expect(m.state.rows).toHaveLength(2);expect(id).toBe("account-1");
|
||||
expect(m.state.fatigueRows).toHaveLength(2);expect(m.state.fatigueRows?.[0]?.state).toBe("creative_decline");
|
||||
expect(m.state.fatigueRows?.[0]?.baseline.days).toBe(7);expect(m.state.fatigueRows?.[0]?.recent.days).toBe(3);
|
||||
await m.handle(m.state.fatigueRows![0]!.key);
|
||||
expect(m.state.rows.map(r=>r.state)).toEqual(["potential","under_target"]);expect(new Set(m.state.rows.map(r=>r.key)).size).toBe(2);
|
||||
await m.handle(m.state.rows[0]!.key);expect((await m.check()).newAlerts).toBe(0);
|
||||
await restored.init();expect(restored.state.rows[0]!.handled).toBe(true);expect(restored.state.fatigueRows?.[0]?.handled).toBe(true);
|
||||
fail=true;expect((await restored.check()).error).toContain("1 个账户检查失败");expect(restored.state.rows).toHaveLength(1);expect(restored.state.accounts?.[1]?.error).toContain("权限");expect(id).toBe("account-1");
|
||||
}finally{m.close();restored.close();await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
Reference in New Issue
Block a user