保存前端调整前的当前状态

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
LongtailandCopilot committed 2026-10-31 17:55:39 +08:00
1 parent f0be4f5596
commit 77012b93c6
10 files changed
+204 -3

No files matched your search

+1
View File
@@ -8,6 +8,7 @@
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
- `browser.test.cjs`:本地页面、移动布局与模拟编辑器;从不请求真实论坛。
- `queue-ui.test.cjs`:独立模拟队列的选择、全选、轮询保留选择、取消/失败、批量移除、运行与当前任务保护、空列表及手机布局。Node 环境可加载 Playwright 后运行 `node test/queue-ui.test.cjs`;不启动本地服务、不访问真实队列。
- `artifacts/`:本轮页面截图,属于生成产物。
原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// Isolated UI regression: all requests use fixture data, never the user's queue.
const {chromium}=require('playwright');
const fs=require('node:fs');
const path=require('node:path');
const assert=require('node:assert/strict');
const ROOT=path.resolve(__dirname,'..');
(async()=>{
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
try{
const page=await browser.newPage({viewport:{width:1440,height:1000}}),errors=[],requests=[];
page.on('pageerror',error=>errors.push(error.message));
const data={run:{running:false,active:'active',config:{},reason:'已暂停',batch:null},clients:[],
jobs:['a','b','active'].map(id=>({id,title:'测试文章 '+id,original_title:'测试文章 '+id,image_count:0,source:'测试来源',state:id==='active'?'awaiting_user_review':'queued'})),
download:{status:'idle',saved:0,target:0,skipped:0,failed:0,logs:[]}};
let failRemoval=false;
await page.route('**/*',async route=>{
const url=new URL(route.request().url());
if(url.hostname!=='queue.test')return route.abort();
if(url.pathname==='/api/session')return route.fulfill({json:{token:'fixture'}});
if(url.pathname==='/api/state')return route.fulfill({json:data});
if(url.pathname==='/api/remove'){
const body=route.request().postDataJSON();requests.push(body);
if(failRemoval)return route.fulfill({status:400,json:{error:'模拟移除失败'}});
data.jobs=data.jobs.filter(job=>!body.ids.includes(job.id));
return route.fulfill({json:{ok:true,removed:body.ids.length}});
}
const files={'/':'index.html','/app.js':'app.js','/style.css':'style.css'};
if(files[url.pathname])return route.fulfill({path:path.join(ROOT,'static',files[url.pathname])});
return route.abort();
});
await page.goto('http://queue.test/');
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===3);
const box=id=>page.locator(`.job-select[value="${id}"]`);
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
assert.equal(await box('active').isDisabled(),true);
await box('a').check();
assert.equal(await page.locator('#select-all').evaluate(el=>el.indeterminate),true);
await page.evaluate(()=>refresh());
assert.equal(await box('a').isChecked(),true,'polling preserves selection');
page.once('dialog',dialog=>dialog.dismiss());
await page.locator('#remove-selected').click();
assert.equal(requests.length,0,'cancel does not delete');
failRemoval=true;
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.getByText('模拟移除失败',{exact:true}).waitFor();
assert.equal(await box('a').isChecked(),true,'failure preserves selection');
failRemoval=false;
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.waitForFunction(()=>document.querySelectorAll('.job-select').length===2);
assert.deepEqual(requests.at(-1),{ids:['a']});
assert.equal(await box('b').count(),1,'unselected article remains');
await page.locator('#select-all').check();
assert.equal(await box('b').isChecked(),true);
assert.equal(await box('active').isChecked(),false);
data.run.running=true;await page.evaluate(()=>refresh());
assert.equal(await page.locator('#remove-selected').isDisabled(),true,'running flow blocks removal');
data.run.running=false;data.run.active=null;await page.evaluate(()=>refresh());
await page.locator('#select-all').check();
await page.setViewportSize({width:390,height:844});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'mobile has no horizontal overflow');
page.once('dialog',dialog=>dialog.accept());
await page.locator('#remove-selected').click();
await page.locator('#empty').waitFor({state:'visible'});
assert.equal(await page.locator('#total').textContent(),'0');
assert.equal(await page.locator('#select-all').isDisabled(),true);
assert.equal(await page.locator('#remove-selected').isDisabled(),true);
assert.deepEqual(errors,[]);
console.log('PASS queue UI: selection, polling, cancellation, failure, selected deletion, protected task, running state, select all, empty state, mobile layout');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exitCode=1;});
+13
View File
@@ -84,6 +84,19 @@ class ControllerTests(unittest.TestCase):
self.assertIsNone(store.run['active'])
def test_ui_remove_selected_articles(self):
store = self.controller.store
store.enqueue(ROOT / 'test/fixtures/article')
ids = [job['id'] for job in store.state()['jobs']]
with self.assertRaises(HTTPError):
self.request('/api/remove', {'ids': ids}, token=self.controller.bridge_token)
with self.assertRaises(HTTPError):
self.request('/bridge/remove', {'ids': ids}, token=self.controller.bridge_token)
code, body = self.request('/api/remove', {'ids': ids}, token=self.controller.ui_token)
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)['removed'], 1)
self.assertEqual(store.state()['jobs'], [])
def test_ui_historical_result_reconciliation(self):
store = self.controller.store
store.enqueue(ROOT / 'test/fixtures/article')
+50
View File
@@ -278,6 +278,56 @@ class QueueTests(unittest.TestCase):
self.assertTrue(saved['identity_needs_reconciliation'])
self.assertFalse(restarted.run['running'])
def test_remove_selected_preserves_files_and_survives_restart(self):
self.store.enqueue(self.article)
other = self.root / 'other'
shutil.copytree(self.article, other)
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容'))
self.store.enqueue(other)
jobs = self.store.state()['jobs']
result = self.store.remove({'ids': [jobs[0]['id'], jobs[0]['id']]})
self.assertEqual(result['removed'], 1)
self.assertTrue(self.md.is_file())
restarted = Store(self.root / 'queue.sqlite3')
self.assertEqual([j['id'] for j in restarted.state()['jobs']], [jobs[1]['id']])
self.assertEqual(restarted.enqueue(self.article), '已入队')
self.assertEqual(restarted.remove({'ids': [j['id'] for j in restarted.state()['jobs']]})['removed'], 2)
self.assertEqual(restarted.state()['jobs'], [])
def test_remove_is_atomic_and_protects_current_task(self):
job = self.begin()
with self.assertRaisesRegex(ValueError, '暂停'):
self.store.remove({'ids': [job['id']]})
self.store.control('pause')
with self.assertRaisesRegex(ValueError, '当前任务'):
self.store.remove({'ids': [job['id']]})
self.store.resolve(dict(id=job['id'], confirmed_not_accepted=True, retry=False))
for ids in ([], 'invalid', [None], [job['id'], 'missing']):
with self.assertRaises(ValueError):
self.store.remove({'ids': ids})
self.assertEqual(len(self.store.state()['jobs']), 1)
self.store.remove({'ids': [job['id']]})
self.assertEqual(self.store.state()['jobs'], [])
def test_remove_accepted_retains_deduplication_and_counts(self):
job = self.begin(); self.submit(job); self.accept(job)
self.store.control('pause')
before = self.store.state()['history_count']
self.store.remove({'ids': [job['id']]})
self.assertEqual(self.store.state()['jobs'], [])
self.assertEqual(self.store.state()['history_count'], before)
self.assertEqual(self.store.run['batch']['count'], 1)
self.assertIn('已受理', self.store.enqueue(self.article))
with self.store.db() as db:
self.assertEqual(db.execute('SELECT count(*) FROM posts').fetchone()[0], 1)
def test_remove_unresolved_submission_is_blocked_even_without_active_pointer(self):
job = self.begin(); self.submit(job); self.store.control('pause')
self.store.run['active'] = None
with self.assertRaisesRegex(ValueError, '尚未核对'):
self.store.remove({'ids': [job['id']]})
self.assertEqual(len(self.store.state()['jobs']), 1)
def test_bad_result_does_not_increment(self):
job = self.begin();self.submit(job)
with self.assertRaises(ValueError):