const {chromium}=require('playwright');
const assert=require('node:assert/strict');
const favoriteKey=f=>`image-model-collection:favorites:v1:${f}`;
const settingsKey=f=>`image-model-collection:settings:v1:${f}`;
(async()=>{
const browser=await chromium.launch({headless:true,...(process.env.TEST_CHROME?{executablePath:process.env.TEST_CHROME}:{})});
try {
for(const mobile of [false,true]) {
const page=await browser.newPage({viewport:mobile?{width:390,height:844}:{width:1360,height:1000},isMobile:mobile,hasTouch:mobile,acceptDownloads:true});
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
let accept=true; page.on('dialog',d=>accept?d.accept():d.dismiss());
await page.goto('http://127.0.0.1:7868');
const panel=page.locator('.bf-panel:visible');
const prompt=page.getByRole('textbox',{name:'Prompt',exact:true});
const number=label=>page.getByRole('spinbutton',{name:`number input for ${label}`,exact:true});
const data=()=>page.evaluate(k=>JSON.parse(localStorage.getItem(k)),favoriteKey('anima'));
const count=async n=>{await page.waitForFunction(({k,n})=>JSON.parse(localStorage.getItem(k))?.entries.length===n,{k:favoriteKey('anima'),n});};
const importData=async object=>panel.locator('.bf-file').setInputFiles({name:'favorites.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(object))});
await page.locator('.bs-settings:visible .bs-enabled:not(:disabled)').waitFor({state:'attached'});
assert.equal(await panel.evaluate(el=>el.open),false);
assert(await page.evaluate(()=>{
const visible=selector=>[...document.querySelectorAll(selector)].find(el=>el.offsetParent!==null);
const lora=visible('.lora-accordion'), samples=visible('.jujo-samples');
const generate=[...document.querySelectorAll('button')].find(el=>el.textContent.trim()==='Generate with Anima');
return Boolean(lora.compareDocumentPosition(samples)&Node.DOCUMENT_POSITION_FOLLOWING)&&Boolean(samples.compareDocumentPosition(generate)&Node.DOCUMENT_POSITION_FOLLOWING);
}),'LoRA -> JujoHotaru -> Generate order');
await panel.locator('summary').click();
assert.equal(await panel.locator('.bf-apply').innerText(),'設定を読み込む');
assert.match(await panel.innerText(),/サイトデータの削除/);
assert.match(await panel.innerText(),/画像生成は開始しません/);
assert.match(await page.locator('.generation-count-notice:visible').first().innerText(),/モデルの入れ替え・ラインナップ改善/);
assert.match(await page.locator('.generation-count-notice:visible').first().innerText(),/利用者を識別する情報は含みません/);
await panel.locator('.bf-name').fill('水彩');
await prompt.fill('
favorite prompt');
await number('CFG').fill('3.2'); await number('CFG').press('Tab');
await panel.locator('.bf-add').click(); await count(1);
let first=(await data()).entries[0];
assert.equal(first.values.prompt,'
favorite prompt');
assert.equal(first.values.cfg,3.2); assert.equal(first.values.seed,'-1');
assert.equal(await panel.locator('b,img').count(),0);
await prompt.fill('changed'); await panel.locator('.bf-apply').click();
await panel.locator('.bf-message').filter({hasText:'設定を読み込みました'}).waitFor();
assert.equal(await prompt.inputValue(),first.values.prompt);
await prompt.fill('updated'); accept=false; await panel.locator('.bf-update').click();
assert.equal((await data()).entries[0].values.prompt,first.values.prompt);
accept=true; await panel.locator('.bf-update').click();
await page.waitForFunction(k=>JSON.parse(localStorage.getItem(k)).entries[0].values.prompt==='updated',favoriteKey('anima'));
const downloadPromise=page.waitForEvent('download'); await panel.locator('.bf-export').click();
const download=await downloadPromise; const stream=await download.createReadStream(); const chunks=[];for await(const chunk of stream)chunks.push(chunk);
assert.equal(download.suggestedFilename(),'anima-collection-plus-mcp-favorites-anima.json');
const exported=JSON.parse(Buffer.concat(chunks)); assert.equal(exported.entries.length,1);
assert.equal(exported.format,'anima-collection-plus-mcp-favorites');
await importData({...exported,format:'imggen-favorites'}); await count(2);
assert.equal((await data()).entries[0].name,(await data()).entries[1].name);
await panel.locator('.bf-delete').click(); await count(1);
await page.reload(); await page.locator('.bs-settings:visible .bs-enabled:not(:disabled)').waitFor({state:'attached'});
await panel.locator('summary').click(); assert.equal(await panel.locator('.bf-list option').count(),2);
await panel.locator('.bf-list').selectOption({index:1});
await panel.locator('.bf-seed').selectOption('last'); await panel.locator('.bf-name').fill('last seed');
await panel.locator('.bf-add').click(); await panel.locator('.bf-message').filter({hasText:'まだ生成が完了していません'}).waitFor();
await page.getByRole('button',{name:'Generate with Anima',exact:true}).click();
await page.getByText(/Received LoRA slots:.*seed 3952492001372691585/).waitFor();
await panel.locator('.bf-add').click(); await count(2);
assert.equal((await data()).entries[1].values.seed,'3952492001372691585');
await panel.locator('.bf-apply').click(); await panel.locator('.bf-message').filter({hasText:'設定を読み込みました'}).waitFor();
await page.getByRole('button',{name:'Generate with Anima',exact:true}).click();
await page.getByText(/Received LoRA slots:.*seed 3952492001372691585/).waitFor();
const actualSeed=page.locator('input[aria-label*="Seed"]:visible');
await actualSeed.fill('42'); await actualSeed.press('Tab');
await page.waitForTimeout(500);
await page.getByRole('button',{name:'Generate with Anima',exact:true}).click();
await page.getByText(/Received LoRA slots:.*seed 42$/).waitFor();
// Automatic prompt opt-out does not silently wipe explicit favorites.
const settings=page.locator('.bs-settings:visible'); await settings.locator('summary').click();
await settings.locator('.bs-prompts').uncheck(); await settings.locator('.bs-enabled').uncheck();
assert.equal((await data()).entries.length,2);
await panel.locator('.bf-seed').selectOption('input'); await panel.locator('.bf-name').fill('explicit');
await prompt.fill('explicit while auto save off'); await panel.locator('.bf-add').click(); await count(3);
assert.equal((await data()).entries[2].values.prompt,'explicit while auto save off');
// Reject malformed/wrong-family/range/oversized imports atomically.
for(const invalid of [{...exported,family:'illustrious'}, {...exported,entries:[{name:'x',values:{cfg:999}}]}, {...exported,entries:Array(101).fill(exported.entries[0])}]) {
await importData(invalid); await panel.locator('.bf-message').filter({hasText:'インポートできません'}).waitFor(); assert.equal((await data()).entries.length,3);
}
const missing={...exported,entries:[{name:'missing LoRA',values:{...first.values,lora3_file:'removed.safetensors',lora3_enabled:true,unexpected:'not stored'}}]};
await importData(missing); await count(4); assert(!Object.hasOwn((await data()).entries[3].values,'unexpected'));
await panel.locator('.bf-list').selectOption({index:4}); await panel.locator('.bf-apply').click();
await panel.locator('.bf-message').filter({hasText:'未登録の項目'}).waitFor();
await page.getByRole('button',{name:'LoRA設定・追加モデル(最大4つ)'}).click();
assert.equal(await page.getByRole('checkbox',{name:'LoRA 3 Enable',exact:true}).isChecked(),false);
await page.getByRole('tab',{name:'Illustrious',exact:true}).click();
await page.locator('.bs-settings:visible .bs-enabled:not(:disabled)').waitFor({state:'attached'});
await panel.locator('summary').click(); assert.equal(await panel.locator('.bf-list option').count(),1);
await panel.locator('.bf-name').fill('ILL'); await panel.locator('.bf-add').click();
await page.waitForFunction(k=>JSON.parse(localStorage.getItem(k))?.entries.length===1,favoriteKey('illustrious'));
await page.getByRole('tab',{name:'Anima',exact:true}).click();
const beforePrompt=await prompt.inputValue(); const beforeAuto=await page.evaluate(k=>localStorage.getItem(k),settingsKey('anima'));
accept=false; await panel.locator('.bf-clear').click(); assert.equal((await data()).entries.length,4);
accept=true; await panel.locator('.bf-clear').click();
assert.equal(await data(),null); assert.equal(await prompt.inputValue(),beforePrompt);
assert.equal(await page.evaluate(k=>localStorage.getItem(k),settingsKey('anima')),beforeAuto);
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).entries.length,favoriteKey('illustrious')),1);
await importData({...exported,entries:Array.from({length:100},(_,i)=>({...exported.entries[0],name:(i<50?'水彩ABC ':'油彩xyz ')+i}))}); await count(100);
assert(await panel.locator('.bf-add').isDisabled());
await importData(exported); await panel.locator('.bf-message').filter({hasText:'100件を超えます'}).waitFor(); await count(100);
await page.evaluate(()=>{const original=Storage.prototype.setItem; window.restoreStorage=()=>Storage.prototype.setItem=original;Storage.prototype.setItem=function(){throw Error('quota test');};});
await panel.locator('.bf-list').selectOption({index:1}); await panel.locator('.bf-update').click();
await panel.locator('.bf-message').filter({hasText:'失敗しました'}).waitFor(); await count(100);
await page.evaluate(()=>window.restoreStorage());
await panel.locator('.bf-search').fill('水彩abc');
assert.equal(await panel.locator('.bf-list option').count(),51);
assert.match(await panel.locator('.bf-count').innerText(),/50件表示/全100件/);
await panel.locator('.bf-list').selectOption({index:1});
assert(!await panel.locator('.bf-apply').isDisabled());
await panel.locator('.bf-search').fill('油彩XYZ');
assert.equal(await panel.locator('.bf-list').inputValue(),'');
assert(await panel.locator('.bf-update').isDisabled()); assert(await panel.locator('.bf-delete').isDisabled());
assert.equal(await panel.locator('.bf-name').inputValue(),'');
await panel.locator('.bf-search').fill('not found');
assert.equal(await panel.locator('.bf-list option').count(),1);
assert.match(await panel.locator('.bf-list').innerText(),/一致するお気に入りがありません/);
await count(100);
const allDownloadPromise=page.waitForEvent('download'); await panel.locator('.bf-export').click();
const allDownload=await allDownloadPromise; const allStream=await allDownload.createReadStream(); const allChunks=[];
for await(const chunk of allStream)allChunks.push(chunk);
assert.equal(JSON.parse(Buffer.concat(allChunks)).entries.length,100);
await panel.locator('.bf-search').fill(''); assert.equal(await panel.locator('.bf-list option').count(),101);
await panel.locator('.bf-search').fill('水彩');
await page.getByRole('tab',{name:'Illustrious',exact:true}).click(); assert.equal(await panel.locator('.bf-search').inputValue(),'');
await page.getByRole('tab',{name:'Anima',exact:true}).click();
await panel.locator('.bf-clear').click(); assert.equal(await data(),null);
assert.equal(await page.evaluate(k=>JSON.parse(localStorage.getItem(k)).entries.length,favoriteKey('illustrious')),1);
assert(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth+2));
assert.deepEqual(errors,[]); await page.close();
console.log(`PASS ${mobile?'mobile':'desktop'} 100 favorites, name search, filtered full export/clear, CRUD, JSON, exact seed and mode isolation`);
}
} finally {await browser.close();}
})().catch(e=>{console.error(e);process.exit(1);});