190 lines
12 KiB
Python
190 lines
12 KiB
Python
"""Check generated content and export contact sheets for visual review."""
|
||
from pathlib import Path
|
||
from collections import Counter
|
||
import json
|
||
import re
|
||
import unicodedata
|
||
import argparse
|
||
import zipfile
|
||
import pymupdf
|
||
import openpyxl
|
||
from PIL import Image, ImageDraw
|
||
from pptx import Presentation
|
||
|
||
ROOT=Path(__file__).resolve().parents[1]
|
||
parser=argparse.ArgumentParser()
|
||
parser.add_argument('--recalculated-workbook',type=Path,help='Optional independent LibreOffice/Excel recalculation copy')
|
||
args=parser.parse_args()
|
||
OUT=ROOT/'materials'
|
||
PREV=OUT/'preview'
|
||
PREV.mkdir(exist_ok=True)
|
||
p=Presentation(OUT/'excel-ai-lecture.pptx')
|
||
assert len(p.slides)==26
|
||
manifest=json.loads((OUT/'slides-manifest.json').read_text(encoding='utf-8'))
|
||
assert sum(s['seconds'] for s in manifest)==3600
|
||
notes=(OUT/'speaker-notes.md').read_text(encoding='utf-8')
|
||
durations=[int(v) for v in re.findall(r'\*\*Длительность:\*\* (\d+) секунд',notes)]
|
||
assert durations==[s['seconds'] for s in manifest],durations
|
||
assert all(s.notes_slide.notes_text_frame.text.strip() for s in p.slides)
|
||
for n,s in enumerate(p.slides,1):
|
||
for sh in s.shapes:
|
||
assert sh.left>=0 and sh.top>=0,(n,sh.name,'negative position')
|
||
assert sh.left+sh.width<=p.slide_width+10000,(n,sh.name,'too wide')
|
||
assert sh.top+sh.height<=p.slide_height+10000,(n,sh.name,'too tall')
|
||
|
||
book=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=True)
|
||
rows=list(book['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True))
|
||
assert len(rows)==60 and len({r[0] for r in rows})==60
|
||
assert all(all(v is not None for v in r[:6]) for r in rows)
|
||
assert Counter(r[7] for r in rows)=={'Завершено':24,'Просрочено':18,'В срок':18}
|
||
assert sum(r[5]=='Выполнено' and r[6]>r[4] for r in rows)==12
|
||
assert all(r[6] is None or r[6]<=book['Параметры']['B2'].value for r in rows)
|
||
assert book['Проверка']['B6'].value==.5
|
||
assert len(book['Реестр'].tables)==1
|
||
assert len(book['Старт'].tables)==0 and len(book['Старт 2'].tables)==0
|
||
# Teaching progression: simple IF on Старт, nested IF on Старт 2, identical to Реестр.
|
||
simple=[book['Старт'].cell(r,8).value for r in range(2,62)]
|
||
nested=[book['Старт 2'].cell(r,8).value for r in range(2,62)]
|
||
assert simple==['Закрыто' if r[5]=='Выполнено' else 'В работе' for r in rows]
|
||
assert nested==[r[7] for r in rows]
|
||
formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False)
|
||
assert all(formulas['Реестр'].cell(r,8).data_type=='f' for r in range(2,62))
|
||
assert all(formulas['Старт'].cell(r,8).data_type=='f' for r in range(2,62))
|
||
assert all(formulas['Старт 2'].cell(r,8).data_type=='f' for r in range(2,62))
|
||
assert 'IF(F2="Выполнено","Закрыто","В работе")' in formulas['Старт'].cell(2,8).value
|
||
assert formulas['Старт 2'].cell(2,8).value.count('IF(')==2
|
||
# Dropdowns: every editable field is a pick list backed by a named range on Параметры.
|
||
statuses=[book['Параметры'].cell(r,1).value for r in range(5,8)]
|
||
depts=[book['Параметры'].cell(r,1).value for r in range(10,15)]
|
||
cats=[book['Параметры'].cell(r,1).value for r in range(17,20)]
|
||
expected_dv={('Старт','B'):'AllowedDepartments',('Старт','C'):'AllowedCategories',('Старт','F'):'AllowedStatuses',
|
||
('Старт 2','B'):'AllowedDepartments',('Старт 2','C'):'AllowedCategories',('Старт 2','F'):'AllowedStatuses',
|
||
('Реестр','B'):'AllowedDepartments',('Реестр','C'):'AllowedCategories',('Реестр','F'):'AllowedStatuses'}
|
||
for (sheet,column),name in expected_dv.items():
|
||
found=[dv for dv in formulas[sheet].data_validations.dataValidation if f'{column}2:{column}1000'==str(dv.sqref)]
|
||
assert found,(sheet,column,'нет выпадающего списка')
|
||
assert found[0].type=='list' and found[0].formula1==name,(sheet,column,found[0].formula1)
|
||
assert all(r[1] in depts for r in rows),( 'подразделение вне списка')
|
||
assert all(r[2] in cats for r in rows),'категория вне списка'
|
||
assert all(r[5] in statuses for r in rows),'статус вне списка'
|
||
# Формульный отчёт на листе «Эталон отчёта» (с 15-й строки) должен сходиться со статическим эталоном.
|
||
rep=book['Эталон отчёта']
|
||
static={rep.cell(r,1).value:(rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value,rep.cell(r,5).value)
|
||
for r in range(4,9)}
|
||
assert len(static)==5
|
||
for r in range(17,22):
|
||
dept=rep.cell(r,1).value
|
||
done,late,soon,total=static[dept]
|
||
assert (rep.cell(r,2).value,rep.cell(r,3).value,rep.cell(r,4).value)==(total,done,late),(dept,'отчёт на формулах')
|
||
assert rep.cell(r,5).value==soon,(dept,'в срок')
|
||
assert abs(rep.cell(r,6).value-late/(late+soon))<1e-9,(dept,'доля просроченных среди открытых')
|
||
assert (rep.cell(22,2).value,rep.cell(22,3).value,rep.cell(22,4).value,rep.cell(22,5).value)==(60,24,18,18)
|
||
assert abs(rep.cell(22,6).value-.5)<1e-9
|
||
assert rep.cell(24,2).value=='Совпадает' and rep.cell(25,2).value=='Совпадает'
|
||
report_formulas=openpyxl.load_workbook(OUT/'excel-practice.xlsx',data_only=False)['Эталон отчёта']
|
||
assert all(report_formulas.cell(r,2).data_type=='f' for r in list(range(17,23))+[24,25])
|
||
assert 'COUNTIFS' in report_formulas.cell(17,3).value and 'Реестр' in report_formulas.cell(17,3).value
|
||
# Лист с диаграммами включается флагом --with-chart-sheet. Проверяем его только если он есть.
|
||
chart_sheet=book['Диаграмма'] if 'Диаграмма' in book.sheetnames else None
|
||
if chart_sheet is None:
|
||
chart_status='лист «Диаграмма» не включён (ожидаемо: диаграммы строятся на лекции вживую)'
|
||
with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive:
|
||
assert not [n for n in archive.namelist() if n.startswith('xl/charts/')],'найдены диаграммы без листа'
|
||
else:
|
||
for n,dept in enumerate(static):
|
||
r=2+n
|
||
done,late,soon,total=static[dept]
|
||
assert chart_sheet.cell(r,1).value==dept
|
||
assert (chart_sheet.cell(r,2).value,chart_sheet.cell(r,3).value,chart_sheet.cell(r,4).value)==(done,late,soon),(dept,'блок диаграммы')
|
||
assert (chart_sheet.cell(7,2).value,chart_sheet.cell(7,3).value,chart_sheet.cell(7,4).value)==(24,18,18)
|
||
with zipfile.ZipFile(OUT/'excel-practice.xlsx') as archive:
|
||
chart_parts=[n for n in archive.namelist() if n.startswith('xl/charts/chart') and n.endswith('.xml')]
|
||
assert len(chart_parts)==2,chart_parts
|
||
blob=''.join(archive.read(n).decode('utf-8') for n in chart_parts)
|
||
assert 'Диаграмма!' in blob and 'Реестр!' not in blob,'диаграммы должны ссылаться на лист Диаграмма'
|
||
assert 'Диаграмма!$A$2:$A$6' in blob,'нет категорий по подразделениям'
|
||
assert 'Диаграмма!$B$7:$D$7' in blob,'круговая диаграмма должна брать строку ИТОГО'
|
||
chart_status='лист «Диаграмма»: блок данных и две диаграммы совпадают с эталоном'
|
||
for r in rows:
|
||
expected='Завершено' if r[5]=='Выполнено' else ('Просрочено' if r[4]<book['Параметры']['B2'].value else 'В срок')
|
||
assert expected==r[7]
|
||
assert all(book['Тесты'].cell(n,4).value==book['Тесты'].cell(n,5).value for n in range(2,6))
|
||
recalc_status='not performed in this check'
|
||
if args.recalculated_workbook:
|
||
recalculated=openpyxl.load_workbook(args.recalculated_workbook,data_only=True)
|
||
assert list(recalculated['Реестр'].iter_rows(min_row=2,max_row=61,values_only=True))==rows
|
||
for n in range(2,10):
|
||
assert recalculated['Проверка'].cell(n,2).value==book['Проверка'].cell(n,2).value
|
||
assert all(recalculated['Тесты'].cell(n,4).value==recalculated['Тесты'].cell(n,5).value for n in range(2,6))
|
||
assert [recalculated['Старт'].cell(r,8).value for r in range(2,62)]==simple
|
||
assert [recalculated['Старт 2'].cell(r,8).value for r in range(2,62)]==nested
|
||
recalc_report=recalculated['Эталон отчёта']
|
||
for r in range(17,23):
|
||
for col in range(2,7):
|
||
a,b=recalc_report.cell(r,col).value,rep.cell(r,col).value
|
||
same=abs(a-b)<1e-9 if isinstance(a,(int,float)) and isinstance(b,(int,float)) else a==b
|
||
assert same,(r,col,a,b)
|
||
assert recalc_report.cell(24,2).value=='Совпадает' and recalc_report.cell(25,2).value=='Совпадает'
|
||
if chart_sheet is not None:
|
||
recalc_charts=recalculated['Диаграмма']
|
||
for r in range(2,8):
|
||
for col in range(2,5):
|
||
assert recalc_charts.cell(r,col).value==chart_sheet.cell(r,col).value,(r,col)
|
||
recalc_status='independent recalculation matches 60 rows, both teaching sheets, formula report, totals and boundary tests'
|
||
pdf=pymupdf.open(OUT/'excel-ai-lecture.pdf')
|
||
assert len(pdf)==26
|
||
|
||
def norm(t):
|
||
return ''.join(c for c in unicodedata.normalize('NFKC',t).lower() if c.isalnum())
|
||
|
||
warnings=[]
|
||
for i,s in enumerate(p.slides):
|
||
page=pdf[i]
|
||
pdf_text=norm(page.get_text())
|
||
for sh in s.shapes:
|
||
if not sh.has_text_frame:continue
|
||
for para in sh.text_frame.paragraphs:
|
||
expected=norm(para.text)
|
||
if expected and expected not in pdf_text:
|
||
warnings.append(f'Slide {i+1}: text not found intact: {para.text}')
|
||
for block in page.get_text('dict')['blocks']:
|
||
if 'lines' not in block:continue
|
||
for line in block['lines']:
|
||
for span in line['spans']:
|
||
x0,y0,x1,y1=span['bbox']
|
||
if min(x0,y0)<-1 or x1>page.rect.width+1 or y1>page.rect.height+1:
|
||
warnings.append(f'Slide {i+1}: text exceeds page: {span["text"]}')
|
||
page.get_pixmap(matrix=pymupdf.Matrix(1.4,1.4)).save(PREV/f'slide-{i+1:02}.png')
|
||
|
||
for start in range(0,26,8):
|
||
ids=list(range(start,min(start+8,26)))
|
||
sheet=Image.new('RGB',(1200,360*((len(ids)+1)//2)),'#dedee8')
|
||
for k,i in enumerate(ids):
|
||
pm=pdf[i].get_pixmap(matrix=pymupdf.Matrix(.625,.625))
|
||
im=Image.frombytes('RGB',(pm.width,pm.height),pm.samples)
|
||
x=(k%2)*600;y=(k//2)*360
|
||
sheet.paste(im,(x,y))
|
||
ImageDraw.Draw(sheet).text((x+12,y+339),f'SLIDE {i+1:02}',fill='black')
|
||
sheet.save(PREV/f'contact-{start//8+1}.png')
|
||
|
||
try:
|
||
import zxingcpp
|
||
result=zxingcpp.read_barcode(Image.open(OUT/'telegram-qr.png'))
|
||
assert result and result.text=='https://t.me/kirill_bouko_it'
|
||
# Verify QR survives PDF export, not only its standalone source image.
|
||
result=zxingcpp.read_barcode(Image.open(PREV/'slide-25.png'))
|
||
assert result and result.text=='https://t.me/kirill_bouko_it'
|
||
qr_status='decoded from PNG and PDF slide 25'
|
||
except ImportError:
|
||
qr_status='decoder unavailable; image generated from exact URL'
|
||
|
||
report={
|
||
'slides':26,'total_seconds':3600,'notes_embedded':True,'pdf_pages':len(pdf),
|
||
'synthetic_rows':60,'completed':24,'open':36,'overdue_open':18,'overdue_share_open':.5,
|
||
'qr':qr_status,'render_text_warnings':warnings,'chart_sheet':chart_status,
|
||
'recalculated_workbook':recalc_status,
|
||
}
|
||
(OUT/'validation.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||
print(json.dumps(report,ensure_ascii=False,indent=2))
|
||
assert not warnings,'Inspect PDF text warnings before delivering'
|