upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme, registry tests, and design docs. Re-applied full patch set on identical anchors (discovery/env_for/blocks.css/type-mapping/locale-override/ theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins. Verified: theme suites 35/35; dual-baseline full run 415 passed vs pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails). Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact, trade-pro build 203 pages 0 errors. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -133,7 +133,7 @@ def test_theme_gallery_and_compatibility(factory):
|
||||
ver=factory.post(f'/api/sites/{sid}/builds',json={}).json()['version']
|
||||
html=factory.get(f'/preview/{sid}/{ver}/product/unique/').text
|
||||
assert 'pro-gallery-thumbs' in html and 'data-gallery-image' in html
|
||||
assert 'pro.js' in html
|
||||
assert 'auto-compiled.js' in html or 'pro.js' in html
|
||||
|
||||
|
||||
def test_css_style_breakout_rejected(factory):
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""V8.24: persisted 2-level layout editing, safe public rendering and migration compatibility."""
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from app import blueprints
|
||||
from tests.test_factory import client,create
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def make_nested():
|
||||
root=blueprints.new_block('layout')
|
||||
inner=blueprints.new_block('layout',{'gap':18})
|
||||
leaf=blueprints.new_block('image_text',{'title':'Inner leaf title','description':'Nested content'})
|
||||
inner['columns'][0]['blocks']=[leaf]
|
||||
root['columns'][1]['blocks']=[inner]
|
||||
return root,inner,leaf
|
||||
|
||||
|
||||
def test_deep_nested_output_valid_and_safe():
|
||||
root,inner,leaf=make_nested()
|
||||
clean=blueprints.validate_blocks([root])
|
||||
markup=blueprints.render_blocks(clean,{'name':'Example'},{'title':'Home'},[])
|
||||
soup=BeautifulSoup(markup,'html.parser')
|
||||
assert soup.select_one(f'.sf-layout-grid[data-layout="{root["id"]}"]')
|
||||
assert soup.select_one(f'.sf-layout-grid[data-layout="{inner["id"]}"]')
|
||||
assert soup.select_one(f'.sf-layout-column .sf-section[data-block="{leaf["id"]}"]')
|
||||
assert 'Inner leaf title' in soup.get_text(' ')
|
||||
assert 'siteforge-studio-canvas' not in markup # never emitted into public HTML
|
||||
|
||||
|
||||
def test_reject_exceeding_allowed_nested_layout_depth():
|
||||
root,inner,_=make_nested()
|
||||
extra=blueprints.new_block('layout')
|
||||
inner['columns'][0]['blocks'].append(extra)
|
||||
with pytest.raises(ValueError,match='最多嵌套两层'):
|
||||
blueprints.validate_blocks([root])
|
||||
|
||||
|
||||
def test_reject_duplicate_ids_in_deep_nested_layout():
|
||||
root,inner,leaf=make_nested()
|
||||
inner['columns'][1]['blocks'].append({**leaf})
|
||||
with pytest.raises(ValueError,match='重复'):
|
||||
blueprints.validate_blocks([root])
|
||||
|
||||
|
||||
def test_deep_nested_draft_and_history_round_trip(client):
|
||||
sid=create(client)
|
||||
eid=next(row['id'] for row in client.get(f'/api/sites/{sid}/entries').json() if row['route']=='/')
|
||||
root,inner,leaf=make_nested()
|
||||
url=f'/api/sites/{sid}/pages/{eid}/studio-draft'
|
||||
saved=client.put(url,json={'blocks':[root],'page_template':'home','layout_mode':'replace','expected_revision':0})
|
||||
assert saved.status_code==200,saved.text
|
||||
loaded=client.get(url);assert loaded.status_code==200
|
||||
assert loaded.json()['blocks'][0]['columns'][1]['blocks'][0]['columns'][0]['blocks'][0]['id']==leaf['id']
|
||||
assert client.put(url,json={'blocks':[root],'page_template':'home','layout_mode':'replace','expected_revision':0}).status_code==409
|
||||
|
||||
|
||||
def test_deep_nested_editor_helpers_and_isolated_script():
|
||||
helper=(ROOT/'app/static/editor-helpers.js').read_text()
|
||||
editor=(ROOT/'app/static/editor.js').read_text()
|
||||
bridge=(ROOT/'app/static/studio-canvas-bridge.js').read_text()
|
||||
assert 'findChildInRoot' in helper
|
||||
assert 'findLayout' in helper
|
||||
assert 'setNestedLayoutPreset' in editor
|
||||
assert 'setNestedLayoutGap' in editor
|
||||
assert 'childQuickEditor' in editor
|
||||
assert 'siteforge-studio-canvas' in bridge
|
||||
assert 'event.source!==frame.contentWindow' in editor
|
||||
assert 'eval(' not in bridge and 'new Function(' not in bridge
|
||||
|
||||
|
||||
def test_nested_preview_endpoint_and_responsive_values(client):
|
||||
sid=create(client)
|
||||
eid=next(row['id'] for row in client.get(f'/api/sites/{sid}/entries').json() if row['route']=='/')
|
||||
root,inner,leaf=make_nested()
|
||||
inner['mobile_columns']=1
|
||||
inner['tablet_columns']=2
|
||||
inner['gap']=32
|
||||
preview=client.post(f'/api/sites/{sid}/pages/{eid}/editor-preview',json={
|
||||
'blocks':[root],'page_template':'home','layout_mode':'replace'})
|
||||
assert preview.status_code==200,preview.text[:600]
|
||||
assert 'Inner leaf title' in preview.text
|
||||
assert 'data-layout="'+inner['id']+'"' in preview.text
|
||||
assert 'studio-canvas-bridge.js' in preview.text or 'siteforge-studio-canvas' in preview.text
|
||||
|
||||
|
||||
def test_editor_uses_no_json_input_for_deep_nested_components():
|
||||
source=(ROOT/'app/static/editor.js').read_text()
|
||||
for feature in ('addNestedChild','setNestedDeviceColumns','setNestedLayoutGap','setNestedLayoutPreset',
|
||||
'findChildInRoot','findLayout','Studio.applyMedia(r.url)'):
|
||||
assert feature in source
|
||||
|
||||
|
||||
def test_save_nested_leaf_as_site_scoped_reusable_component(client):
|
||||
sid=create(client)
|
||||
_,_,leaf=make_nested()
|
||||
created=client.post(f'/api/sites/{sid}/components',json={'name':'海运图文', 'block':leaf})
|
||||
assert created.status_code==200,created.text
|
||||
cid=created.json()['id']
|
||||
loaded=client.get(f'/api/sites/{sid}/components')
|
||||
assert loaded.status_code==200
|
||||
assert any(row['id']==cid and row['block']['title']=='Inner leaf title' for row in loaded.json())
|
||||
stale=client.put(f'/api/sites/{sid}/components/{cid}',json={
|
||||
'name':'不允许覆盖','expected_version':0,'block':leaf})
|
||||
assert stale.status_code==409
|
||||
@@ -18,8 +18,9 @@ def test_editor_add_and_direct_canvas_selection():
|
||||
api=(ROOT/'app/main.py').read_text()
|
||||
for method in ('toggleLibrary(force)','updateGuidance()','showHelp()','select(i)','arrayEditor(field,value,type)','itemField(field,index,key,value)','addItem(field)','removeItem(field,index)','moveItem(field,index,by)'):
|
||||
assert method in js
|
||||
assert "siteforge-select-block" in js
|
||||
assert "siteforge-select-block" in api
|
||||
assert "siteforge-studio-canvas" in js
|
||||
assert "studio-canvas-bridge.js" in api
|
||||
assert "safeCanvasCommand" in js
|
||||
assert 'event.source!==frame.contentWindow' in js
|
||||
assert "studio-draft/publish" in js
|
||||
assert 'expected_revision' in js
|
||||
@@ -30,4 +31,4 @@ def test_no_change_to_block_blueprint_or_database_schema():
|
||||
assert demo.exists()
|
||||
assert '演示操作不会修改正式网站' in demo.read_text()
|
||||
from app.version import VERSION
|
||||
assert VERSION.startswith('8.21.') and VERSION.endswith('-dev.1')
|
||||
assert VERSION.startswith('8.') and VERSION.endswith('-dev.1')
|
||||
|
||||
@@ -66,7 +66,8 @@ def test_complete_bootstrap_repeated_safe(factory):
|
||||
assert all(r['route'] for r in records)
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
html=factory.get(f'/preview/{sid}/{build["version"]}/').text
|
||||
assert 'sf-site-head' in html and 'factory.css' in html
|
||||
assert 'sf-site-head' in html
|
||||
assert 'auto-compiled.css' in html or 'factory.css' in html
|
||||
assert factory.get(f'/preview/{sid}/{build["version"]}/assets/site-runtime.js').status_code==200
|
||||
|
||||
def test_mapping_preview_commit_and_cross_site(factory):
|
||||
@@ -104,7 +105,7 @@ def test_editor_supports_responsive_settings(factory):
|
||||
assert r.status_code==200,r.text
|
||||
preview=factory.post(f'/api/sites/{sid}/pages/{homepage["id"]}/editor-preview',json={'blocks':blocks,'page_template':'home'})
|
||||
assert preview.status_code==200
|
||||
assert 'siteforge-inline-editor' in preview.text and '--sf-pad:19px' in preview.text
|
||||
assert 'siteforge-studio-canvas' in preview.text and '--sf-pad:19px' in preview.text
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
css=factory.get(f'/preview/{sid}/{build["version"]}/assets/custom.css').text
|
||||
assert '--sf-pad:19px' in css and 'display:none' in css
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const H=require('../app/static/editor-helpers.js');
|
||||
const layout={id:'layout_0001',type:'layout',columns:[{width:1,blocks:[{id:'child_0001',type:'cta',title:'A'},{id:'child_0002',type:'cta',title:'B'}]},{width:1,blocks:[]}]};
|
||||
const rows=[layout];const spec={cta:{fields:['title','subtitle','href']}};
|
||||
assert.equal(H.findNestedChild(rows,'layout_0001','child_0002').child.title,'B');
|
||||
assert.equal(H.findNestedChild(rows,'layout_0001','unknown'),null);
|
||||
assert.equal(H.findNestedChild(rows,'unknown','child_0001'),null);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-select',blockId:'layout_0001',childId:'child_0001'},rows,spec),true);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-inline-text',blockId:'layout_0001',childId:'child_0001',field:'title',value:'New'},rows,spec),true);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-inline-text',blockId:'layout_0001',childId:'child_0001',field:'body',value:'<script>'},rows,spec),false);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-delete',blockId:'other',childId:'child_0001'},rows,spec),false);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-execute',blockId:'layout_0001',childId:'child_0001'},rows,spec),false);
|
||||
assert.equal(H.safeNestedCanvasCommand({action:'child-inline-text',blockId:'layout_0001',childId:'child_0001',field:'title',value:'x'.repeat(501)},rows,spec),false);
|
||||
assert.deepEqual(H.layoutPreset(layout,'equal'),[1,1]);
|
||||
assert.deepEqual(H.layoutPreset(layout,'left-wide'),[2,1]);
|
||||
assert.deepEqual(H.layoutPreset(layout,'right-wide'),[1,2]);
|
||||
assert.equal(H.layoutPreset(layout,'script'),null);
|
||||
assert.deepEqual(H.moveNested(layout,0,1,-1).map(x=>x.id),['child_0002','child_0001']);
|
||||
assert.equal(H.moveNested(layout,0,0,-1),null);
|
||||
const cp=H.duplicate(layout.columns[0].blocks[0],()=> 'child_0003');assert.equal(cp.id,'child_0003');
|
||||
assert.equal(layout.columns[0].blocks[0].id,'child_0001');
|
||||
console.log('PASS: V8.23 nested helper checks');
|
||||
@@ -0,0 +1,57 @@
|
||||
"""V8.23 nested authoring contract. These checks execute against the real renderer."""
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import blueprints
|
||||
from tests.test_factory import client,create
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_nested_ids_are_rendered_and_duplicate_ids_rejected():
|
||||
layout=blueprints.new_block('layout')
|
||||
child=blueprints.new_block('image_text',{'title':'Subcomponent editing'})
|
||||
layout['columns'][0]['blocks']=[child]
|
||||
clean=blueprints.validate_blocks([layout]);assert clean[0]['columns'][0]['blocks'][0]['id']==child['id']
|
||||
html=blueprints.render_blocks(clean,{}, {},[])
|
||||
soup=BeautifulSoup(html,'html.parser')
|
||||
assert soup.select_one(f'.sf-layout-grid[data-layout="{layout["id"]}"]')
|
||||
assert soup.select_one(f'.sf-layout-column .sf-section[data-block="{child["id"]}"]')
|
||||
assert 'onload=' not in html
|
||||
layout['columns'][1]['blocks']=[child]
|
||||
import pytest
|
||||
with pytest.raises(ValueError,match='重复'):blueprints.validate_blocks([layout])
|
||||
|
||||
|
||||
def test_nested_view_is_only_in_draft_editor():
|
||||
editor=(ROOT/'app/static/editor.js').read_text(encoding='utf-8')
|
||||
bridge=(ROOT/'app/static/studio-canvas-bridge.js').read_text(encoding='utf-8')
|
||||
helpers=(ROOT/'app/static/editor-helpers.js').read_text(encoding='utf-8')
|
||||
for marker in ('child-select','child-inline-text','child-duplicate','child-delete'):
|
||||
assert marker in bridge and marker in editor
|
||||
assert 'safeNestedCanvasCommand' in helpers
|
||||
assert 'childQuickEditor' in editor and 'setLayoutPreset' in editor
|
||||
assert 'event.source!==frame.contentWindow' in editor
|
||||
assert 'eval(' not in bridge and 'new Function(' not in bridge
|
||||
assert 'studio-canvas-bridge.js' not in (ROOT/'app/builder.py').read_text(encoding='utf-8')
|
||||
|
||||
|
||||
def test_editor_sandbox_keeps_scripts_isolated():
|
||||
soup=BeautifulSoup((ROOT/'app/templates/editor.html').read_text(encoding='utf-8'),'html.parser')
|
||||
assert soup.select_one('#sitePreview')['sandbox']==['allow-scripts']
|
||||
assert soup.select_one('#saveDraftButton')
|
||||
|
||||
|
||||
def test_nested_layout_draft_round_trip(client):
|
||||
sid=create(client)
|
||||
entries=client.get(f'/api/sites/{sid}/entries').json()
|
||||
eid=next(row['id'] for row in entries if row['route']=='/')
|
||||
parent=blueprints.new_block('layout')
|
||||
child=blueprints.new_block('image_text',{'title':'Nested draft stable'})
|
||||
parent['columns'][0]['blocks']=[child]
|
||||
uri=f'/api/sites/{sid}/pages/{eid}/studio-draft'
|
||||
payload={'blocks':[parent],'page_template':'home','layout_mode':'replace','expected_revision':0}
|
||||
put=client.put(uri,json=payload)
|
||||
assert put.status_code==200,put.text
|
||||
result=client.get(uri).json()
|
||||
assert result['blocks'][0]['columns'][0]['blocks'][0]['title']=='Nested draft stable'
|
||||
assert result['revision']==1
|
||||
assert client.put(uri,json=payload).status_code==409
|
||||
@@ -19,7 +19,10 @@ def test_four_complete_theme_families_have_distinct_markup(factory):
|
||||
assert soup.find('header',class_='pro-header')
|
||||
assert soup.find('footer',class_='pro-footer')
|
||||
assert len(soup.find_all('h1'))==1,(theme,page_type,len(soup.find_all('h1')))
|
||||
assert 'pro-base.css' in r.text and 'pro.js' in r.text
|
||||
if theme == 'automotive':
|
||||
assert 'preview.bundle.css' in r.text and 'preview.bundle.js' in r.text
|
||||
else:
|
||||
assert 'pro-base.css' in r.text and 'pro.js' in r.text
|
||||
if page_type=='product' and theme!='trade-pro':assert soup.select_one('.pro-product-layout .pro-specs') # trade-pro covered by test_theme_registry
|
||||
if page_type=='listing':assert soup.select_one('[data-pro-filter-search]')
|
||||
if page_type=='article':assert soup.select_one('.pro-article-hero')
|
||||
@@ -65,8 +68,9 @@ def test_theme_build_derived_category_brand_search_and_seo(factory):
|
||||
|
||||
def test_builtin_version_pin_is_real(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
versions=factory.get('/api/factory/themes/automotive/versions').json()
|
||||
assert {v['version'] for v in versions}=={'1.1.0','1.0.0','0.3.0'}
|
||||
assert {v['version'] for v in versions}=={'1.4.1','1.4.0','1.3.0','1.2.0','1.1.0','1.0.0','0.3.0'}
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'0.3.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
old=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
@@ -74,7 +78,7 @@ def test_builtin_version_pin_is_real(factory):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'1.0.0'}).status_code==200
|
||||
b=factory.post(f'/api/sites/{sid}/builds',json={}).json()
|
||||
new=factory.get(f'/preview/{sid}/{b["version"]}/').text
|
||||
assert 'pro-auto-hero' in new
|
||||
assert 'pro-auto-hero' in new and 'auto31-home' not in new
|
||||
|
||||
|
||||
def test_custom_theme_package_uses_trusted_layout_and_css(factory):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""V8.22 studio canvas wiring and editor-published separation."""
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_canvas_resources_are_real_and_editor_only():
|
||||
bridge=(ROOT/'app/static/studio-canvas-bridge.js').read_text(encoding='utf-8')
|
||||
source=(ROOT/'app/main.py').read_text(encoding='utf-8')
|
||||
editor=(ROOT/'app/static/editor.js').read_text(encoding='utf-8')
|
||||
assert 'studio-canvas-bridge.js' in source
|
||||
assert 'studio-canvas-bridge.js' not in (ROOT/'app/builder.py').read_text(encoding='utf-8')
|
||||
for command in ('reorder','resize-columns','add-after','duplicate','delete','inline-text'):
|
||||
assert command in editor and command in bridge
|
||||
assert 'eval(' not in bridge and 'new Function(' not in bridge
|
||||
assert 'event.source!==frame.contentWindow' in editor
|
||||
|
||||
def test_editor_canvas_hints_and_save_remain_accessible():
|
||||
soup=BeautifulSoup((ROOT/'app/templates/editor.html').read_text(encoding='utf-8'),'html.parser')
|
||||
assert soup.select_one('#sfInsertHint')['hidden'] == ''
|
||||
assert soup.select_one('#sitePreview')['sandbox']==['allow-scripts']
|
||||
assert soup.select_one('#saveDraftButton')
|
||||
assert soup.select_one('#publishDesignButton')
|
||||
@@ -0,0 +1,209 @@
|
||||
"""V8.25 opt-in shared component correctness, isolation and draft safety."""
|
||||
import copy
|
||||
import json
|
||||
import pytest
|
||||
from tests.test_factory import client,create
|
||||
from app import db,blueprints
|
||||
|
||||
|
||||
def home(c,site):
|
||||
return next(row for row in c.get(f'/api/sites/{site}/entries').json() if row['route']=='/')
|
||||
|
||||
|
||||
def library(c,site,title='SHARED_OLD'):
|
||||
block=blueprints.new_block('cta',{'title':title})
|
||||
r=c.post(f'/api/sites/{site}/components',json={'name':'公共行动区','block':block})
|
||||
assert r.status_code==200,r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def link(c,site,entry,item):
|
||||
block=copy.deepcopy(item['block'])
|
||||
block['id']='link_'+entry['id'][:10]
|
||||
block['shared_ref']={'id':item['id'],'version':item['version'],'hash':item['snapshot_hash']}
|
||||
url=f'/api/sites/{site}/pages/{entry["id"]}/studio-draft'
|
||||
draft=c.get(url).json()
|
||||
r=c.put(url,json={'blocks':[block],'page_template':'standard','layout_mode':'replace','expected_revision':draft['revision']})
|
||||
assert r.status_code==200,r.text
|
||||
return url,block
|
||||
|
||||
|
||||
def change(c,site,item,title='SHARED_NEW'):
|
||||
updated=blueprints.new_block('cta',{'title':title})
|
||||
r=c.put(f'/api/sites/{site}/components/{item["id"]}',json={
|
||||
'name':'公共行动区','expected_version':item['version'],'block':updated})
|
||||
assert r.status_code==200,r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_linked_usage_and_explicit_sync_never_modifies_publication(client):
|
||||
site=create(client);entry=home(client,site);original=db.one('SELECT meta_json FROM entries WHERE id=?',(entry['id'],))['meta_json']
|
||||
item=library(client,site);url,block=link(client,site,entry,item)
|
||||
v2=change(client,site,item)
|
||||
base=f'/api/sites/{site}/components/{item["id"]}'
|
||||
preview=client.get(base+'/usage').json()
|
||||
assert preview['references'][0]['eligible']
|
||||
assert preview['references'][0]['block_id']==block['id']
|
||||
assert preview['references'][0]['current_version']==1
|
||||
r=client.post(base+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]})
|
||||
assert r.status_code==200,r.text
|
||||
assert r.json()['published_unchanged']
|
||||
newer=client.get(url).json()
|
||||
assert newer['revision']==2
|
||||
assert newer['blocks'][0]['title']=='SHARED_NEW'
|
||||
assert newer['blocks'][0]['id']==block['id']
|
||||
assert newer['blocks'][0]['shared_ref']['version']==2
|
||||
assert db.one('SELECT meta_json FROM entries WHERE id=?',(entry['id'],))['meta_json']==original
|
||||
assert client.get(base+'/usage').json()['references'][0]['up_to_date']
|
||||
history=client.get(f'/api/sites/{site}/pages/{entry["id"]}/studio-history').json()['items']
|
||||
assert len(history)==2
|
||||
v1=client.get(base+'/versions/1').json();v2view=client.get(base+'/versions/2').json()
|
||||
assert v1['block']['title']=='SHARED_OLD' and v2view['block']['title']=='SHARED_NEW'
|
||||
assert v1['snapshot_hash']!=v2['snapshot_hash']
|
||||
|
||||
|
||||
def test_edited_instances_are_not_overwritten(client):
|
||||
site=create(client);entry=home(client,site);item=library(client,site)
|
||||
url,block=link(client,site,entry,item)
|
||||
current=client.get(url).json()
|
||||
current['blocks'][0]['title']='LOCALLY_CUSTOMIZED'
|
||||
payload={'blocks':current['blocks'],'page_template':'standard','layout_mode':'replace','expected_revision':1}
|
||||
assert client.put(url,json=payload).status_code==200
|
||||
change(client,site,item)
|
||||
root=f'/api/sites/{site}/components/{item["id"]}'
|
||||
preview=client.get(root+'/usage').json()
|
||||
assert preview['references'][0]['local_modified']
|
||||
assert not preview['references'][0]['eligible']
|
||||
response=client.post(root+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]})
|
||||
assert response.status_code==409
|
||||
assert client.get(url).json()['blocks'][0]['title']=='LOCALLY_CUSTOMIZED'
|
||||
|
||||
|
||||
def test_stale_preview_and_foreign_site_protected(client):
|
||||
a=create(client);b=create(client);entry=home(client,a)
|
||||
item=library(client,a);url,_=link(client,a,entry,item)
|
||||
change(client,a,item)
|
||||
src=f'/api/sites/{a}/components/{item["id"]}'
|
||||
preview=client.get(src+'/usage').json()
|
||||
assert client.get(f'/api/sites/{b}/components/{item["id"]}/usage').status_code==404
|
||||
assert client.get(f'/api/sites/{b}/components/{item["id"]}/versions').status_code==404
|
||||
assert client.post(f'/api/sites/{b}/components/{item["id"]}/sync',json={
|
||||
'plan_token':preview['plan_token'],'entry_ids':[entry['id']]}).status_code==404
|
||||
draft=client.get(url).json()
|
||||
assert client.put(url,json={**{k:draft[k] for k in ('blocks','page_template','layout_mode')},
|
||||
'expected_revision':draft['revision']}).status_code==200
|
||||
assert client.post(src+'/sync',json={'plan_token':preview['plan_token'],'entry_ids':[entry['id']]}).status_code==409
|
||||
|
||||
|
||||
def test_unlinked_copies_not_counted_and_deletion_blocked(client):
|
||||
site=create(client);item=library(client,site);entry=home(client,site)
|
||||
block=copy.deepcopy(item['block'])
|
||||
r=client.put(f'/api/sites/{site}/pages/{entry["id"]}/studio-draft',json={
|
||||
'blocks':[block],'page_template':'standard','expected_revision':0})
|
||||
assert r.status_code==200
|
||||
root=f'/api/sites/{site}/components/{item["id"]}'
|
||||
assert client.get(root+'/usage').json()['references']==[]
|
||||
link(client,site,entry,item)
|
||||
assert client.delete(root).status_code==409
|
||||
other=library(client,site,'OTHER')
|
||||
assert client.delete(f'/api/sites/{site}/components/{other["id"]}').status_code==200
|
||||
|
||||
|
||||
def test_invalid_component_ref_rejected(client):
|
||||
site=create(client);entry=home(client,site)
|
||||
item=blueprints.new_block('cta')
|
||||
item['shared_ref']={'id':'<script>','version':1,'hash':'0'*64}
|
||||
r=client.put(f'/api/sites/{site}/pages/{entry["id"]}/studio-draft',json={
|
||||
'blocks':[item],'page_template':'standard','expected_revision':0})
|
||||
assert r.status_code==400
|
||||
|
||||
|
||||
def test_multiple_page_sync_rolls_back_on_conflict(client):
|
||||
site=create(client);item=library(client,site)
|
||||
one=home(client,site)
|
||||
second=client.post(f'/api/sites/{site}/entries',json={'type':'page','title':'Other','route':'/second/'}).json()
|
||||
assert second['id']
|
||||
first_url,_=link(client,site,one,item);second_url,_=link(client,site,second,item)
|
||||
change(client,site,item)
|
||||
base=f'/api/sites/{site}/components/{item["id"]}'
|
||||
usage=client.get(base+'/usage').json()
|
||||
assert len(usage['references'])==2
|
||||
err=client.post(base+'/sync',json={'entry_ids':[one['id'],second['id']],'plan_token':'f'*64})
|
||||
assert err.status_code==409
|
||||
assert client.get(first_url).json()['revision']==1
|
||||
result=client.post(base+'/sync',json={'entry_ids':[one['id'],second['id']],
|
||||
'plan_token':usage['plan_token']})
|
||||
assert result.status_code==200,result.text
|
||||
assert len(result.json()['updated'])==2
|
||||
assert client.get(first_url).json()['revision']==2
|
||||
assert client.get(second_url).json()['revision']==2
|
||||
|
||||
|
||||
def test_two_page_sync_is_a_single_transaction(client,monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from app import reusables
|
||||
site=create(client);item=library(client,site)
|
||||
a=home(client,site)
|
||||
b=client.post(f'/api/sites/{site}/entries',json={'type':'page','title':'Second','route':'/multi/'}).json()
|
||||
a_url,_=link(client,site,a,item)
|
||||
b_url,_=link(client,site,b,item)
|
||||
change(client,site,item)
|
||||
base=f'/api/sites/{site}/components/{item["id"]}'
|
||||
usage=client.get(base+'/usage').json()
|
||||
original=reusables._copy_into
|
||||
called=[0]
|
||||
def break_on_second(*args,**kwargs):
|
||||
called[0]+=1
|
||||
if called[0]==2:raise HTTPException(409,'模拟第二页失败')
|
||||
return original(*args,**kwargs)
|
||||
monkeypatch.setattr(reusables,'_copy_into',break_on_second)
|
||||
response=client.post(base+'/sync',json={
|
||||
'entry_ids':[a['id'],b['id']],'plan_token':usage['plan_token']})
|
||||
assert response.status_code==409
|
||||
assert called[0]==2
|
||||
assert client.get(a_url).json()['revision']==1
|
||||
assert client.get(b_url).json()['revision']==1
|
||||
|
||||
|
||||
def test_published_link_is_not_republished_by_library_sync(client):
|
||||
site=create(client);page=home(client,site);item=library(client,site)
|
||||
url,_=link(client,site,page,item)
|
||||
assert client.post(url+'/publish',json={'expected_revision':1}).status_code==200
|
||||
before=db.one('SELECT meta_json FROM entries WHERE id=?',(page['id'],))['meta_json']
|
||||
assert 'shared_ref' in before
|
||||
change(client,site,item)
|
||||
base=f'/api/sites/{site}/components/{item["id"]}'
|
||||
usage=client.get(base+'/usage').json()
|
||||
assert usage['references'][0]['eligible'] and not usage['references'][0]['has_draft']
|
||||
response=client.post(base+'/sync',json={'plan_token':usage['plan_token'],'entry_ids':[page['id']]})
|
||||
assert response.status_code==200,response.text
|
||||
assert db.one('SELECT meta_json FROM entries WHERE id=?',(page['id'],))['meta_json']==before
|
||||
assert client.get(url).json()['state']=='draft'
|
||||
|
||||
|
||||
def test_repeated_migration_and_recursive_detach(client):
|
||||
from app import reusables
|
||||
site=create(client)
|
||||
other=library(client,site)
|
||||
layout=blueprints.new_block('layout')
|
||||
nested=blueprints.new_block('cta')
|
||||
nested['shared_ref']={'id':other['id'],'version':1,'hash':other['snapshot_hash']}
|
||||
layout['columns'][0]['blocks']=[nested]
|
||||
saved=client.post(f'/api/sites/{site}/components',json={'name':'新布局','block':layout})
|
||||
assert saved.status_code==200,saved.text
|
||||
assert 'shared_ref' not in saved.json()['block']['columns'][0]['blocks'][0]
|
||||
reusables.setup();reusables.setup()
|
||||
history=client.get(f'/api/sites/{site}/components/{saved.json()["id"]}/versions').json()
|
||||
assert len(history['items'])==1
|
||||
|
||||
|
||||
def test_shared_editor_has_clear_actions():
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
html=(root/'app/templates/editor.html').read_text()
|
||||
js=(root/'app/static/editor.js').read_text()
|
||||
assert 'sharedComponentModal' in html
|
||||
for method in ('sharedUsage','sharedApply','sharedVersions','detachShared','insertReusable'):
|
||||
assert method in js
|
||||
assert '复制:独立编辑' in html
|
||||
assert '关联插入' in js
|
||||
@@ -0,0 +1,104 @@
|
||||
"""V8.26 global chrome review, version snapshots, and safe restoration."""
|
||||
import copy
|
||||
import json
|
||||
from tests.test_factory import client, create
|
||||
from app import db, site_design_v87
|
||||
|
||||
|
||||
def config(marker='Global HESU Header'):
|
||||
data=copy.deepcopy(site_design_v87.DEFAULTS)
|
||||
data['header'].update({'announcement': marker, 'show_announcement': True})
|
||||
data['footer']['title']='HESU Footer'
|
||||
data['nav']=[{'title':'Products','route':'/products/','children':[
|
||||
{'title':'Vehicles','route':'/vehicles/','children':[]}]}]
|
||||
return data
|
||||
|
||||
|
||||
def test_human_review_and_reviewed_publication_are_build_isolated(client):
|
||||
site=create(client);url=f'/api/sites/{site}/site-design'
|
||||
old=db.one('SELECT settings_json FROM sites WHERE id=?',(site,))['settings_json']
|
||||
assert client.put(url,json={'config':config(), 'expected_revision':0}).status_code==200
|
||||
proof=client.get(url+'/review');assert proof.status_code==200,proof.text
|
||||
body=proof.json()
|
||||
assert {'header','nav','footer'}.issubset(set(body['changed_sections']))
|
||||
assert body['published_site_unchanged'] is True
|
||||
assert body['change_count']>=3
|
||||
assert db.one('SELECT settings_json FROM sites WHERE id=?',(site,))['settings_json']==old
|
||||
wrong=client.post(url+'/publish-reviewed',json={'expected_revision':1,'plan_token':'1'*64})
|
||||
assert wrong.status_code==409
|
||||
good=client.post(url+'/publish-reviewed',json={'expected_revision':1,'plan_token':body['plan_token']})
|
||||
assert good.status_code==200,good.text
|
||||
assert 'Global HESU Header' in db.one('SELECT settings_json FROM sites WHERE id=?',(site,))['settings_json']
|
||||
assert client.get(url+'/review').status_code==409
|
||||
|
||||
|
||||
def test_old_review_token_invalid_after_draft_edit(client):
|
||||
site=create(client);url=f'/api/sites/{site}/site-design'
|
||||
assert client.put(url,json={'config':config('First'), 'expected_revision':0}).status_code==200
|
||||
stale=client.get(url+'/review').json()
|
||||
assert client.put(url,json={'config':config('Second'), 'expected_revision':1}).status_code==200
|
||||
assert client.post(url+'/publish-reviewed',json={
|
||||
'expected_revision':2,'plan_token':stale['plan_token']}).status_code==409
|
||||
fresh=client.get(url+'/review').json()
|
||||
assert fresh['plan_token']!=stale['plan_token']
|
||||
assert client.post(url+'/publish-reviewed',json={
|
||||
'expected_revision':2,'plan_token':fresh['plan_token']}).status_code==200
|
||||
|
||||
|
||||
def test_restore_history_as_new_draft_does_not_mutate_live(client):
|
||||
site=create(client);url=f'/api/sites/{site}/site-design'
|
||||
assert client.put(url,json={'config':config('Release A'),'expected_revision':0}).status_code==200
|
||||
reviewed=client.get(url+'/review').json()
|
||||
pub=client.post(url+'/publish-reviewed',json={'expected_revision':1,'plan_token':reviewed['plan_token']}).json()
|
||||
pid=pub['publication_id']
|
||||
old_live=db.one('SELECT settings_json FROM sites WHERE id=?',(site,))['settings_json']
|
||||
history=client.get(url+f'/publications/{pid}');assert history.status_code==200
|
||||
assert history.json()['after']['header']['announcement']=='Release A'
|
||||
assert any(x['field']=='header.announcement' for x in history.json()['changes'])
|
||||
live_hash=client.get(url+'/live-hash').json()['live_hash']
|
||||
recovered=client.post(url+f'/publications/{pid}/restore',json={
|
||||
'snapshot':'before','expected_revision':0,'expected_live_hash':live_hash})
|
||||
assert recovered.status_code==200,recovered.text
|
||||
assert recovered.json()['published_unchanged']
|
||||
assert client.get(url).json()['revision']==1
|
||||
assert db.one('SELECT settings_json FROM sites WHERE id=?',(site,))['settings_json']==old_live
|
||||
assert client.post(url+f'/publications/{pid}/restore',json={
|
||||
'snapshot':'before','expected_revision':0,'expected_live_hash':live_hash}).status_code==409
|
||||
|
||||
|
||||
def test_restore_rejects_stale_live_site_and_foreign_history(client):
|
||||
a=create(client);b=create(client)
|
||||
ua=f'/api/sites/{a}/site-design';ub=f'/api/sites/{b}/site-design'
|
||||
assert client.put(ua,json={'config':config(), 'expected_revision':0}).status_code==200
|
||||
preview=client.get(ua+'/review').json()
|
||||
pid=client.post(ua+'/publish-reviewed',json={
|
||||
'expected_revision':1,'plan_token':preview['plan_token']}).json()['publication_id']
|
||||
old_hash=client.get(ua+'/live-hash').json()['live_hash']
|
||||
assert client.get(ub+f'/publications/{pid}').status_code==404
|
||||
assert client.post(ub+f'/publications/{pid}/restore',json={
|
||||
'snapshot':'after','expected_revision':0,'expected_live_hash':old_hash}).status_code==404
|
||||
current=db.one('SELECT settings_json FROM sites WHERE id=?',(a,))
|
||||
changed=json.loads(current['settings_json']);changed['external_setting']='changed'
|
||||
db.execute('UPDATE sites SET settings_json=? WHERE id=?',(json.dumps(changed),a))
|
||||
assert client.post(ua+f'/publications/{pid}/restore',json={
|
||||
'snapshot':'after','expected_revision':0,'expected_live_hash':old_hash}).status_code==409
|
||||
assert client.post(ua+f'/publications/{pid}/restore',json={
|
||||
'snapshot':'bad','expected_revision':0,'expected_live_hash':'a'*64}).status_code==400
|
||||
|
||||
|
||||
def test_nav_editor_is_visual_and_review_ui_is_wired(client):
|
||||
sid=create(client)
|
||||
entry=next(x for x in client.get(f'/api/sites/{sid}/entries').json() if x['route']=='/')
|
||||
page=client.get(f'/editor/{sid}/{entry["id"]}').text
|
||||
assert 'siteNavRows' in page and '+ 添加一级菜单' in page
|
||||
assert 'siteDesignReviewModal' in page and 'siteDesignHistoryModal' in page
|
||||
code=client.get('/ui/editor.js').text
|
||||
assert 'drawSiteNav()' in code and 'publish-reviewed' in code
|
||||
assert 'showSiteDesignHistory' in code
|
||||
|
||||
|
||||
def test_invalid_navigation_remains_rejected(client):
|
||||
sid=create(client);url=f'/api/sites/{sid}/site-design'
|
||||
bad=config();bad['nav'][0]['children'][0]['route']='javascript:alert(1)'
|
||||
assert client.put(url,json={'config':bad,'expected_revision':0}).status_code==400
|
||||
assert client.get(url).json()['revision']==0
|
||||
@@ -0,0 +1,33 @@
|
||||
"""V8.27 novice-facing console: UI wiring + live API smoke."""
|
||||
from pathlib import Path
|
||||
from test_factory import client
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_workspace_has_progress_and_safe_guidance(client):
|
||||
r=client.get('/')
|
||||
assert r.status_code==200
|
||||
for text in ('建站操作指引','createProgressText','createSubmit','选择风格','确认创建','siteModal'):
|
||||
assert text in r.text
|
||||
assert '/ui/workspace-guide.js' in r.text
|
||||
assert r.text.index('/ui/workspace-guide.js')<r.text.index('/ui/workspace.js')
|
||||
assert client.get('/ui/workspace-guide.js').status_code==200
|
||||
|
||||
def test_guide_is_real_navigation_and_real_api(client):
|
||||
js=client.get('/ui/workspace.js').text
|
||||
for phrase in ('function guide()', 'function renderCreateStep()', 'function openCreateWizard()', "await api('/api/sites','POST',data)", "await selectSite(s.id,'guide')"):
|
||||
assert phrase in js
|
||||
assert '正式上线前需要发布条件检查' in client.get('/ui/workspace-guide.js').text
|
||||
create=client.post('/api/sites',json={'name':'引导式新站','theme':'corporate','mode':'new'})
|
||||
assert create.status_code==201
|
||||
data=create.json()
|
||||
entries=client.get(f"/api/sites/{data['id']}/entries").json()
|
||||
assert len(entries)>=1
|
||||
assert any(x['source']=='starter' for x in entries)
|
||||
|
||||
def test_existing_release_controls_and_permission_barriers_preserved(client):
|
||||
assert client.get('/api/console/bootstrap').status_code==200
|
||||
ws=client.get('/ui/workspace.js').text
|
||||
for phrase in ('releasePreflight()', 'releaseApproval()', 'releaseDeploy()', 'releaseRollback('):
|
||||
assert phrase in ws
|
||||
assert client.get('/classic').status_code==200
|
||||
@@ -0,0 +1,37 @@
|
||||
"""V8.28: secured user journey remains mapped to existing FastAPI controls."""
|
||||
from pathlib import Path
|
||||
from test_factory import client
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
def test_launch_center_is_reachable_from_workspace(client):
|
||||
response=client.get('/')
|
||||
assert response.status_code==200
|
||||
assert 'data-view="launch"' in response.text
|
||||
assert '检查并上线' in response.text
|
||||
assert '/ui/workspace-launch.js' in response.text
|
||||
assert client.get('/ui/workspace-launch.js').status_code==200
|
||||
|
||||
def test_steps_use_existing_production_api_and_preserve_csrf(client):
|
||||
script=client.get('/ui/workspace.js').text
|
||||
for name in ('function launch()', 'function launchRequestApproval()', 'function launchPreflight()', 'function launchDeploy()', 'function resetLaunch()'):
|
||||
assert name in script
|
||||
for route in ('/release-requests', '/preflight/', '/deploy'):
|
||||
assert route in script
|
||||
assert "headers['X-SiteForge-CSRF']=state.csrf" in script
|
||||
assert 'Idempotency-Key' in script
|
||||
assert '已经检查此版本' in script
|
||||
assert '提交部署只是创建任务' in script
|
||||
|
||||
def test_preflight_for_nonexistent_build_is_not_marked_ready(client):
|
||||
# The administrative fixture is a superadmin. Missing builds must still fail closed.
|
||||
result=client.get('/api/production/sites/not-your-website/preflight/something')
|
||||
assert result.status_code in (403,404) or (result.status_code==200 and result.json().get('ready') is False)
|
||||
|
||||
def test_new_client_side_gates_do_not_change_server_approval_requirement(client):
|
||||
site=client.post('/api/sites',json={'name':'V828 审批保护','theme':'corporate','mode':'new'})
|
||||
assert site.status_code==201
|
||||
sid=site.json()['id']
|
||||
# Unauthorized to deploy a nonexistent or unapproved version, even if client is bypassed.
|
||||
response=client.post(f'/api/production/sites/{sid}/deploy',json={'build_id':'fake-build','target':'production'})
|
||||
assert response.status_code in (400,403,404,409,503)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""V8.29 theme versioning and editor usability: protected integration checks."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from test_factory_v2 import factory, site
|
||||
from app import theme_store
|
||||
|
||||
THEMES=('automotive','industrial','corporate','minimal')
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_all_themes_preserve_v110_and_publish_v120(factory):
|
||||
for theme in THEMES:
|
||||
assert theme_store.latest(theme)==('1.4.1' if theme=='automotive' else '1.3.0')
|
||||
assert theme_store.resolve(theme,'1.1.0').is_dir()
|
||||
assert theme_store.compatibility(theme,'1.1.0')['valid']
|
||||
assert theme_store.compatibility(theme,'1.2.0')['valid']
|
||||
assert json.loads((theme_store.resolve(theme,'1.1.0')/'manifest.json').read_text())['version']=='1.1.0'
|
||||
|
||||
|
||||
def test_theme_versions_remain_buildable(factory):
|
||||
sid=site(factory)
|
||||
for theme in THEMES:
|
||||
for version in ('1.1.0','1.2.0','1.3.0'):
|
||||
res=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':theme,'version':version})
|
||||
assert res.status_code==200, (theme,version,res.text)
|
||||
res=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert res.status_code==200,(theme,version,res.text)
|
||||
bid=res.json()['version']
|
||||
main=factory.get(f'/preview/{sid}/{bid}/')
|
||||
assert main.status_code==200
|
||||
assert f'pro-{theme}' in main.text
|
||||
css=factory.get(f'/preview/{sid}/{bid}/assets/pro.css')
|
||||
assert css.status_code==200
|
||||
assert ('V8.29 presentation refinement' in css.text)==(version in ('1.2.0','1.3.0'))
|
||||
|
||||
|
||||
def test_theme_preview_6_page_types(factory):
|
||||
for theme in THEMES:
|
||||
for kind in ('home','listing','product','article','about','contact'):
|
||||
res=factory.get(f'/api/factory/themes/{theme}/preview',params={'page_type':kind})
|
||||
assert res.status_code==200,(theme,kind)
|
||||
assert BeautifulSoup(res.text,'html.parser').find('header',class_='pro-header')
|
||||
assert 'V8.30 REAL PAGE COMPOSITION' in factory.get(f'/api/factory/themes/{theme}/preview.pro.css').text
|
||||
|
||||
|
||||
def test_editor_controls_and_safe_preview_dialog(factory):
|
||||
sid=site(factory)
|
||||
# Real authenticated page route; selectors target actual shipped template.
|
||||
res=factory.get(f'/studio/{sid}')
|
||||
if res.status_code==404:
|
||||
import re
|
||||
editor=(ROOT/'app/templates/editor.html').read_text()
|
||||
else:editor=res.text
|
||||
for ident in ('studioThemePreviewButton','themePreviewModal','themePreviewTheme','themePreviewPage','themePreviewFrame','paletteSearch','quickUndo','quickRedo'):
|
||||
assert f'id="{ident}"' in editor
|
||||
assert 'sandbox="allow-scripts"' in editor
|
||||
assert 'allow-same-origin' not in editor
|
||||
assert 'studio-experience-v829.js' in editor
|
||||
@@ -0,0 +1,60 @@
|
||||
"""V8.30 shipped page-composition regression: data-safe, versioned, six real page types."""
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import theme_store
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
THEMES=('automotive','industrial','corporate','minimal')
|
||||
KINDS=('home','listing','product','article','about','contact')
|
||||
|
||||
|
||||
def test_new_and_legacy_page_layouts_are_version_isolated(factory):
|
||||
for name in THEMES:
|
||||
assert theme_store.latest(name)==('1.4.1' if name=='automotive' else '1.3.0')
|
||||
assert theme_store.compatibility(name,'1.2.0')['valid']
|
||||
assert theme_store.compatibility(name,'1.3.0')['valid']
|
||||
old=theme_store.resolve(name,'1.2.0')
|
||||
new=theme_store.resolve(name,'1.3.0')
|
||||
assert 'pro-v30-page' not in (old/'pages/product.html').read_text()
|
||||
assert 'pro-v30-page' in (new/'pages/product.html').read_text()
|
||||
assert 'V8.30 REAL PAGE COMPOSITION' not in (old/'pro.css').read_text()
|
||||
assert 'V8.30 REAL PAGE COMPOSITION' in (new/'pro.css').read_text()
|
||||
|
||||
|
||||
def test_all_six_real_page_types_have_useful_structure(factory):
|
||||
for name in THEMES:
|
||||
for kind in KINDS:
|
||||
response=factory.get(f'/api/factory/themes/{name}/preview',params={'page_type':kind})
|
||||
assert response.status_code==200,(name,kind,response.text[:200])
|
||||
doc=BeautifulSoup(response.text,'html.parser')
|
||||
assert len(doc.select('h1'))==1,(name,kind)
|
||||
assert doc.select_one('.pro-header') and doc.select_one('.pro-footer')
|
||||
if kind=='home':assert doc.select_one('.pro-home-hero')
|
||||
else:assert doc.select_one('.pro-v30-'+kind),(name,kind)
|
||||
if kind=='listing':
|
||||
assert doc.select_one('.pro-filter-scope')
|
||||
assert doc.select_one('[data-pro-filter-search]')
|
||||
if kind=='product':
|
||||
assert doc.select_one('.pro-specs')
|
||||
assert doc.select_one('.pro-product-media')
|
||||
if kind=='article':assert doc.select_one('.pro-v30-reading-content')
|
||||
if kind=='contact':
|
||||
# No fake phone, email or working form without configured destination.
|
||||
assert not doc.select_one('form[action=""]')
|
||||
|
||||
|
||||
def test_new_product_theme_preserves_seo_and_archive_is_buildable(factory):
|
||||
sid=site(factory)
|
||||
for name in THEMES:
|
||||
for version in ('1.2.0','1.3.0'):
|
||||
selected=factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':name,'version':version})
|
||||
assert selected.status_code==200,selected.text
|
||||
built=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert built.status_code==200,built.text
|
||||
bid=built.json()['version']
|
||||
page=factory.get(f'/preview/{sid}/{bid}/')
|
||||
assert page.status_code==200
|
||||
css=factory.get(f'/preview/{sid}/{bid}/assets/pro.css').text
|
||||
assert ('V8.30 REAL PAGE COMPOSITION' in css)==(version=='1.3.0')
|
||||
assert '<meta name="robots"' in page.text
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Real static build + legacy pin smoke for performance and semantic icons."""
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import db, theme_store
|
||||
from test_factory_v2 import factory,site
|
||||
|
||||
|
||||
def test_latest_theme_icons_and_preview_resources(factory):
|
||||
assert theme_store.latest('automotive')=='1.4.1'
|
||||
for kind in ('home','product','brand','listing','contact'):
|
||||
result=factory.get('/api/factory/themes/automotive/preview',params={'page_type':kind})
|
||||
assert result.status_code==200,result.text[:400]
|
||||
doc=BeautifulSoup(result.text,'html.parser')
|
||||
assert len(doc.select('link[rel=stylesheet]'))==1
|
||||
assert len(doc.select('script[src]'))==1
|
||||
assert 'preview.bundle.css' in doc.select_one('link[rel=stylesheet]')['href']
|
||||
assert 'preview.bundle.js' in doc.select_one('script[src]')['src']
|
||||
uses=doc.select('svg.auto-icon use[href]')
|
||||
assert uses
|
||||
assert all('preview.icons.svg' in v['href'] for v in uses)
|
||||
for name in ('bundle.css','bundle.js','icons.svg'):
|
||||
r=factory.get('/api/factory/themes/automotive/preview.'+name)
|
||||
assert r.status_code==200,(name,r.text[:80])
|
||||
assert len(r.content)>250
|
||||
|
||||
|
||||
def test_compiled_assets_in_new_version_and_legacy_original(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
for version in ('1.4.0','1.4.1'):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':version}).status_code==200
|
||||
r=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert r.status_code==200,r.text[:400]
|
||||
bid=r.json()['version']
|
||||
def open_path(path):
|
||||
reply=factory.get(f'/preview/{sid}/{bid}/'+path)
|
||||
assert reply.status_code==200,(version,path,reply.text[:160])
|
||||
return reply
|
||||
home=BeautifulSoup(open_path('').text,'html.parser')
|
||||
if version=='1.4.1':
|
||||
css=home.select('link[rel=stylesheet]')
|
||||
js=home.select('script[src]')
|
||||
assert len(css)==1 and css[0]['href']=='assets/auto-compiled.css'
|
||||
assert len(js)==1 and js[0]['src']=='assets/auto-compiled.js'
|
||||
icons=open_path('assets/auto-icons.svg').text
|
||||
assert '<symbol id="search"' in icons and '<symbol id="chat"' in icons
|
||||
assert '.auto31-search' in open_path('assets/auto-compiled.css').text
|
||||
script=open_path('assets/auto-compiled.js').text
|
||||
assert 'data-pro-menu' in script and 'search-index.json' in script
|
||||
assert all(icon['href'].startswith('assets/auto-icons.svg#') for icon in home.select('use[href]'))
|
||||
assert 'auto-compiled' in open_path('contact/').text
|
||||
assert open_path('products/').status_code==200
|
||||
else:
|
||||
assert len(home.select('link[rel=stylesheet]'))>=6
|
||||
assert len(home.select('script[src]'))>=3
|
||||
assert 'auto-compiled.css' not in str(home)
|
||||
|
||||
|
||||
def test_old_theme_source_is_snapshotted():
|
||||
old=theme_store.resolve('automotive','1.4.0')
|
||||
new=theme_store.resolve('automotive','1.4.1')
|
||||
assert (old/'pro.html').read_text()!= (new/'pro.html').read_text()
|
||||
assert 'auto-icons.svg' not in (old/'pro.html').read_text()
|
||||
assert theme_store.compatibility('automotive','1.4.0')['valid']
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Automotive v1.4.0 information architecture inspired by Magic Bridge Auto.
|
||||
No real external content or vehicle claim is imported by the theme.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from bs4 import BeautifulSoup
|
||||
from app import theme_store
|
||||
from test_factory_v2 import factory, site
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_automotive_new_and_old_versions_remain_available(factory):
|
||||
assert theme_store.latest('automotive')=='1.4.1'
|
||||
for version in ('1.4.0','1.3.0','1.2.0','1.1.0','1.0.0'):
|
||||
p=theme_store.resolve('automotive',version)
|
||||
assert theme_store.compatibility('automotive',version)['valid']
|
||||
assert ('auto31-home' in (p/'pages/home.html').read_text()) == (version == '1.4.0')
|
||||
assert "'pages/' ~ kind" in (p/'pro.html').read_text()
|
||||
assert theme_store.latest('industrial')=='1.3.0'
|
||||
assert 'auto31-home' in (theme_store.resolve('automotive','1.4.0')/'pages/home.html').read_text()
|
||||
|
||||
|
||||
def test_reference_inspired_full_page_structure(factory):
|
||||
checks={'home':('.auto31-home','.auto31-search','.auto31-feature-tile'),
|
||||
'listing':('.auto31-catalog','.pro-filter-panel','.pro-filter-scope'),
|
||||
'brand':('.auto31-brand','#auto31-brand-models','.auto31-brand-story'),
|
||||
'product':('.auto31-detail','.auto31-detail-main','.auto31-spec-panel'),
|
||||
'about':('.pro-v30-about',), 'contact':('.pro-v30-contact',)}
|
||||
for kind,selectors in checks.items():
|
||||
res=factory.get('/api/factory/themes/automotive/preview',params={'page_type':kind})
|
||||
assert res.status_code==200,(kind,res.text[:350])
|
||||
soup=BeautifulSoup(res.text,'html.parser')
|
||||
assert len(soup.select('h1'))==1
|
||||
assert all(soup.select_one(css) for css in selectors),(kind,selectors)
|
||||
assert soup.select_one('[data-pro-menu]')
|
||||
|
||||
|
||||
def test_automotive_search_and_mega_use_real_catalogue_data(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
product=factory.post(f'/api/sites/{sid}/entries',json={
|
||||
'type':'product','title':'Fictional A1','route':'/products/fictional-a1/',
|
||||
'summary':'Demonstration model, not real inventory',
|
||||
'meta':{'fields':{'brand':'DemoMotors','category':'SUV','energy':'EV'}}})
|
||||
assert product.status_code==201,product.text
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':'1.4.0'}).status_code==200
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
version=build.json()['version']
|
||||
def view(route):
|
||||
r=factory.get(f'/preview/{sid}/{version}{route}')
|
||||
assert r.status_code==200,(route,r.text[:250])
|
||||
return r.text
|
||||
home=view('/')
|
||||
assert 'DemoMotors' in home and 'Fictional A1' in home
|
||||
assert 'auto31.js' in home and 'auto31-search' in home
|
||||
catalogue=view('/products/')
|
||||
assert 'Fictional A1' in catalogue
|
||||
assert 'data-pro-filter-search' in catalogue
|
||||
assert 'DemoMotors' in view('/brands/demomotors/')
|
||||
detail=view('/products/fictional-a1/')
|
||||
assert 'Vehicle' in detail or '车辆参数' in detail
|
||||
assert '<meta name="robots"' in detail
|
||||
assert factory.get(f'/preview/{sid}/{version}/assets/auto31.js').status_code==200
|
||||
report=factory.get(f'/api/sites/{sid}/builds/{version}/report').json()
|
||||
assert not report['errors']
|
||||
|
||||
|
||||
def test_v830_archived_template_is_not_v831_home(factory):
|
||||
sid=site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code==200
|
||||
for version in ('1.3.0','1.4.0','1.4.1'):
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme',json={'theme':'automotive','version':version}).status_code==200
|
||||
build=factory.post(f'/api/sites/{sid}/builds',json={})
|
||||
assert build.status_code==200,build.text
|
||||
p=factory.get(f'/preview/{sid}/{build.json()["version"]}/')
|
||||
assert p.status_code==200
|
||||
assert ('auto31-home' in p.text)==(version in ('1.4.0','1.4.1'))
|
||||
assert ('auto31-hero-grid' in p.text)==(version in ('1.4.0','1.4.1'))
|
||||
@@ -9,7 +9,7 @@ def test_default_workspace_has_guided_navigation(client):
|
||||
page=client.get('/')
|
||||
assert page.status_code==200
|
||||
for label in ('工作台','我的网站','网站概览','内容与页面','外观与设计',
|
||||
'导入与迁移','构建与交付','网站设置','高级工具'):
|
||||
'导入与迁移','生成与预览','检查并上线','网站设置','高级工具'):
|
||||
assert label in page.text
|
||||
assert '/ui/workspace.js' in page.text
|
||||
assert '/ui/workspace.css' in page.text
|
||||
|
||||
@@ -47,9 +47,9 @@ def test_safe_import_is_default_and_direct_import_is_explicit(client):
|
||||
|
||||
|
||||
def test_wizard_does_not_claim_design_automatically_finished(client):
|
||||
js=client.get('/ui/workspace.js').text
|
||||
assert 'optional:true,view:\'design\'' in js
|
||||
assert '不自动标记为完成' not in js or '设计可以随时修改' in js
|
||||
js=client.get('/ui/workspace-guide.js').text
|
||||
assert "id:'design'" in js and 'optional:true' in js
|
||||
assert "done:false,optional:true" in js
|
||||
|
||||
|
||||
def test_live_job_endpoint_can_list_user_site_jobs(client):
|
||||
|
||||
Reference in New Issue
Block a user