💻 源码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>pytest + ruff 原理动画</title>
<style>
  :root{
    --bg:#0d1117; --panel:#161b22; --border:#30363d;
    --txt:#c9d1d9; --muted:#8b949e;
    --green:#3fb950; --red:#f85149; --blue:#58a6ff;
    --yellow:#d29922; --purple:#bc8cff; --cyan:#39c5cf;
    --mono:'Cascadia Code','Consolas','SFMono-Regular',Menlo,monospace;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  body{
    background:radial-gradient(1200px 600px at 50% -10%,#1b2330,#0d1117 60%);
    color:var(--txt);font-family:'Segoe UI','Microsoft YaHei',sans-serif;
    min-height:100vh;padding:24px;
  }
  .wrap{max-width:1080px;margin:0 auto}
  h1{font-size:24px;text-align:center;margin-bottom:4px}
  h1 .lint{color:var(--yellow)} h1 .test{color:var(--green)}
  .sub{text-align:center;color:var(--muted);margin-bottom:18px;font-size:14px}

  /* 顶部进度 / 控制 */
  .controls{display:flex;gap:10px;justify-content:center;align-items:center;
    margin-bottom:18px;flex-wrap:wrap}
  button{background:var(--panel);color:var(--txt);border:1px solid var(--border);
    padding:8px 16px;border-radius:8px;cursor:pointer;font-size:14px;transition:.15s}
  button:hover{border-color:var(--blue);color:#fff}
  button.primary{background:var(--blue);color:#fff;border-color:var(--blue)}
  button:disabled{opacity:.4;cursor:not-allowed}
  .dots{display:flex;gap:8px}
  .dot{width:10px;height:10px;border-radius:50%;background:var(--border);transition:.2s}
  .dot.active{background:var(--blue);transform:scale(1.3)}
  .dot.done{background:var(--green)}

  .stage{background:var(--panel);border:1px solid var(--border);border-radius:14px;
    padding:24px;min-height:440px;position:relative;overflow:hidden}
  .scene{display:none;animation:fade .5s ease}
  .scene.active{display:block}
  @keyframes fade{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
  .scene h2{font-size:18px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
  .badge{font-size:12px;padding:2px 10px;border-radius:20px;font-weight:600}
  .badge.static{background:rgba(210,153,34,.15);color:var(--yellow);border:1px solid var(--yellow)}
  .badge.dynamic{background:rgba(63,185,80,.15);color:var(--green);border:1px solid var(--green)}

  /* 代码块 */
  .code{background:#0d1117;border:1px solid var(--border);border-radius:10px;
    padding:14px 16px;font-family:var(--mono);font-size:13.5px;line-height:1.75;
    white-space:pre;overflow-x:auto;position:relative}
  .code .ln{color:#484f58;user-select:none;display:inline-block;width:22px}
  .kw{color:var(--purple)} .fn{color:var(--blue)} .str{color:#a5d6ff}
  .cm{color:#8b949e} .num{color:#79c0ff}

  /* 扫描线 */
  .scanline{position:absolute;left:0;right:0;height:26px;
    background:linear-gradient(90deg,transparent,rgba(88,166,255,.25),transparent);
    border-top:1px solid var(--blue);border-bottom:1px solid var(--blue);
    top:14px;opacity:0;pointer-events:none}
  .scanline.run{animation:scan 2.4s linear forwards;opacity:1}
  @keyframes scan{from{top:14px}to{top:260px}}

  .flow{display:flex;align-items:center;justify-content:center;gap:6px;
    flex-wrap:wrap;margin:18px 0}
  .node{background:#0d1117;border:1px solid var(--border);border-radius:10px;
    padding:12px 14px;text-align:center;min-width:104px;opacity:.3;transition:.4s;font-size:13px}
  .node.on{opacity:1;border-color:var(--blue);box-shadow:0 0 14px rgba(88,166,255,.3)}
  .node .ic{font-size:24px;display:block;margin-bottom:4px}
  .node small{color:var(--muted);display:block;font-size:11px;margin-top:2px}
  .arrow{color:var(--muted);font-size:20px;opacity:.3;transition:.4s}
  .arrow.on{opacity:1;color:var(--blue)}

  .two{display:grid;grid-template-columns:1fr 1fr;gap:16px}
  @media(max-width:760px){.two{grid-template-columns:1fr}}
  .col{background:#0d1117;border:1px solid var(--border);border-radius:10px;padding:14px}
  .col h3{font-size:14px;margin-bottom:10px}
  .col.lint h3{color:var(--yellow)} .col.test h3{color:var(--green)}

  .report{margin-top:14px;font-family:var(--mono);font-size:13px;min-height:24px}
  .report .err{color:var(--red)} .report .ok{color:var(--green)}
  .typed{opacity:0;transform:translateX(-6px);transition:.3s}
  .typed.show{opacity:1;transform:none}

  .pill{display:inline-block;background:#0d1117;border:1px solid var(--border);
    border-radius:6px;padding:2px 8px;font-family:var(--mono);font-size:12px;color:var(--cyan)}
  .note{margin-top:14px;color:var(--muted);font-size:13px;line-height:1.7;
    border-left:3px solid var(--blue);padding-left:12px}
  .key{color:var(--yellow);font-weight:600}

  /* test 灯 */
  .lights{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
  .light{width:30px;height:30px;border-radius:6px;background:var(--border);
    display:flex;align-items:center;justify-content:center;font-size:14px;
    transition:.3s;color:#0d1117;font-weight:700}
  .light.pass{background:var(--green)}
  .light.fail{background:var(--red);color:#fff}

  /* 文件树 */
  .tree,.tree ul{list-style:none;margin:0;padding:0}
  .tree ul{margin-left:18px;border-left:1px dashed var(--border);padding-left:6px;
    max-height:400px;overflow:hidden;transition:max-height .3s ease}
  .tree li{margin:2px 0}
  .tree .row{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;
    cursor:pointer;font-size:13.5px;transition:.12s;user-select:none}
  .tree .row:hover{background:rgba(88,166,255,.12)}
  .tree .row .ic{font-size:14px}
  .tree .tw{display:inline-block;width:12px;color:var(--muted);transition:transform .2s}
  .tree .dir.open>.row .tw{transform:rotate(90deg)}
  .tree .dir:not(.open)>ul{max-height:0}
  .tree .file.sel>.row{background:rgba(88,166,255,.25);color:#fff;
    box-shadow:inset 2px 0 0 var(--blue)}
  #codeView{display:block;color:var(--txt);font-family:var(--mono);font-size:12.5px;
    line-height:1.7;white-space:pre}

  .barwrap{background:#0d1117;border:1px solid var(--border);border-radius:8px;
    height:30px;overflow:hidden;margin-top:8px}
  .bar{height:100%;width:0;background:linear-gradient(90deg,var(--green),var(--cyan));
    transition:width .8s ease;display:flex;align-items:center;justify-content:flex-end;
    padding-right:8px;color:#0d1117;font-weight:700;font-size:12px}
  footer{text-align:center;color:var(--muted);font-size:12px;margin-top:14px}
</style>
</head>
<body>
<div class="wrap">
  <h1><span class="lint">ruff (lint)</span> 与 <span class="test">pytest</span> 工作原理动画</h1>
  <p class="sub">静态分析「看代码」 vs 动态测试「跑代码」——两者都是外部工具,都不会把自己的逻辑写进你的业务代码</p>

  <div class="controls">
    <button id="prev">← 上一步</button>
    <button id="play" class="primary">▶ 自动播放</button>
    <button id="next" class="primary">下一步 →</button>
    <div class="dots" id="dots"></div>
  </div>

  <div class="stage">

    <!-- 场景 0: 总览 -->
    <section class="scene active" data-scene="0">
      <h2>① 两条完全不同的路线</h2>
      <div class="two">
        <div class="col lint">
          <h3>🔍 ruff / lint(静态分析)</h3>
          <div class="code"><span class="cm"># 把源码当“文本”读入</span>
<span class="cm"># 解析成 AST(语法树)</span>
<span class="cm"># 用规则匹配,输出报告</span>
<span class="key" style="color:var(--yellow)"># ❌ 不运行你的程序</span></div>
          <div class="note">关注:风格、未使用变量、import 排序、潜在 bug 写法……</div>
        </div>
        <div class="col test">
          <h3>🧪 pytest(动态测试)</h3>
          <div class="code"><span class="cm"># 收集 test_* 函数</span>
<span class="cm"># 真正调用你的函数</span>
<span class="cm"># 对 assert 求值 → 红/绿</span>
<span class="key" style="color:var(--green)"># ✅ 真的运行你的程序</span></div>
          <div class="note">关注:逻辑对不对、结果是否符合预期、异常是否抛出……</div>
        </div>
      </div>
      <div class="note">👉 二者都是<span class="key">独立的工具 + 独立的文件</span>,运行时不会混入被测业务代码。</div>
    </section>

    <!-- 场景 1: 项目结构(可点击展开) -->
    <section class="scene" data-scene="1">
      <h2>② 项目结构 <span class="badge static">点击文件夹展开 · 点文件看代码</span></h2>
      <div class="two" style="grid-template-columns:0.9fr 1.1fr">
        <div class="col">
          <h3 style="color:var(--cyan)">📦 pytest_doc/ <small style="color:var(--muted);font-weight:400">(点击节点展开/折叠)</small></h3>
          <ul class="tree" id="tree">
            <li class="dir open" data-name="src">
              <span class="row"><span class="tw">▸</span><span class="ic">📁</span> src/</span>
              <ul>
                <li class="file" data-file="calculator.py"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🐍</span> calculator.py</span></li>
              </ul>
            </li>
            <li class="dir open" data-name="tests">
              <span class="row"><span class="tw">▸</span><span class="ic">📁</span> tests/</span>
              <ul>
                <li class="file" data-file="test_calculator.py"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🧪</span> test_calculator.py</span></li>
              </ul>
            </li>
            <li class="file" data-file="pytest.ini"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">⚙️</span> pytest.ini</span></li>
            <li class="file" data-file="pyproject.toml"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">🦀</span> pyproject.toml</span></li>
            <li class="file" data-file="requirements.txt"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">📋</span> requirements.txt</span></li>
            <li class="file" data-file="README.md"><span class="row"><span class="tw" style="visibility:hidden">▸</span><span class="ic">📖</span> README.md</span></li>
          </ul>
          <div class="note" style="font-size:12px">最后一层(文件)就是代码细节,点击右侧查看 →</div>
        </div>
        <div class="col">
          <h3 id="codeTitle" style="color:var(--blue)">👉 点击左侧任意文件查看代码</h3>
          <div class="code" style="min-height:300px;max-height:340px;overflow:auto"><code id="codeView">// 选择一个文件…</code></div>
        </div>
      </div>
    </section>

    <!-- 场景 2: ruff 原理 -->
    <section class="scene" data-scene="2">
      <h2>③ ruff 原理 <span class="badge static">静态分析 · 不执行</span></h2>
      <div class="flow" id="lintflow">
        <div class="node" data-i="0"><span class="ic">📄</span>源码文本<small>calculator.py</small></div>
        <span class="arrow" data-i="0">→</span>
        <div class="node" data-i="1"><span class="ic">🔡</span>词法分析<small>tokens</small></div>
        <span class="arrow" data-i="1">→</span>
        <div class="node" data-i="2"><span class="ic">🌳</span>语法树<small>AST</small></div>
        <span class="arrow" data-i="2">→</span>
        <div class="node" data-i="3"><span class="ic">📐</span>规则匹配<small>E/F/I/B/UP</small></div>
        <span class="arrow" data-i="3">→</span>
        <div class="node" data-i="4"><span class="ic">📋</span>报告<small>不改逻辑</small></div>
      </div>
      <div class="code" style="margin-top:6px">
<div class="scanline" id="scan1"></div><span class="ln">10</span><span class="kw">import</span> pytest
<span class="ln">11</span><span class="kw">from</span> src.calculator <span class="kw">import</span> add, subtract, multiply, divide, is_even
<span class="ln">12</span>
<span class="ln">13</span><span class="kw">def</span> <span class="fn">test_add</span>():
<span class="ln">14</span>    <span class="kw">assert</span> <span class="fn">add</span>(<span class="num">2</span>, <span class="num">3</span>) == <span class="num">5</span>
<span class="ln">15</span>    unused = <span class="num">42</span>   <span class="cm"># 没用到的变量</span></div>
      <div class="report" id="lintreport"></div>
      <div class="note">ruff 全程只是“阅读 + 比对规则”,<span class="key">从不执行</span> <span class="pill">add()</span>。所以它不会把检查逻辑写进代码。</div>
    </section>

    <!-- 场景 3: ruff --fix -->
    <section class="scene" data-scene="3">
      <h2>④ <span class="pill">ruff check --fix</span> 会改写「你的源码格式」</h2>
      <div class="two">
        <div class="col">
          <h3 style="color:var(--red)">修复前</h3>
          <div class="code"><span class="kw">from</span> src.calculator <span class="kw">import</span> add, subtract, multiply, divide, is_even</div>
          <div class="report"><span class="err">I001 Import block is un-sorted</span></div>
        </div>
        <div class="col">
          <h3 style="color:var(--green)">修复后(自动排序)</h3>
          <div class="code"><span class="kw">from</span> src.calculator <span class="kw">import</span> <span id="sorted"></span></div>
          <div class="report"><span class="ok">All checks passed! ✅</span></div>
        </div>
      </div>
      <div class="note">
        ⚠️ 注意区别:它修改的是<span class="key">你自己的代码格式</span>(排序/空格/引号),
        而<span class="key">不是</span>“把 lint 规则塞进你的代码”。两回事。
        <br>同理 <span class="pill">ruff format .</span> 只负责统一排版,不改变程序行为。
      </div>
    </section>

    <!-- 场景 4: pytest 原理 -->
    <section class="scene" data-scene="4">
      <h2>⑤ pytest 原理 <span class="badge dynamic">动态执行 · 真跑代码</span></h2>
      <div class="flow" id="testflow">
        <div class="node" data-i="0"><span class="ic">🔎</span>收集<small>test_*</small></div>
        <span class="arrow" data-i="0">→</span>
        <div class="node" data-i="1"><span class="ic">⚙️</span>调用函数<small>真的运行</small></div>
        <span class="arrow" data-i="1">→</span>
        <div class="node" data-i="2"><span class="ic">⚖️</span>assert 求值<small>比较结果</small></div>
        <span class="arrow" data-i="2">→</span>
        <div class="node" data-i="3"><span class="ic">🚦</span>红 / 绿<small>pass/fail</small></div>
      </div>
      <div class="code" style="margin-top:6px"><span class="kw">def</span> <span class="fn">test_divide_by_zero</span>():
    <span class="kw">with</span> pytest.<span class="fn">raises</span>(ValueError):
        <span class="fn">divide</span>(<span class="num">1</span>, <span class="num">0</span>)   <span class="cm"># ← pytest 真的调用它,看是否抛异常</span></div>
      <div class="lights" id="lights"></div>
      <div class="report" id="testreport"></div>
      <div class="note">pytest <span class="key">真实执行</span>了 <span class="pill">divide(1,0)</span>,捕获到 <span class="pill">ValueError</span> → 这条用例变绿。</div>
    </section>

    <!-- 场景 5: 完整流程 -->
    <section class="scene" data-scene="5">
      <h2>⑥ 完整流程(开发 / CI 质量门禁)</h2>
      <div class="flow" id="ciflow">
        <div class="node" data-i="0"><span class="ic">✍️</span>写代码</div>
        <span class="arrow" data-i="0">→</span>
        <div class="node" data-i="1"><span class="ic">🔧</span>ruff --fix<small>修 lint</small></div>
        <span class="arrow" data-i="1">→</span>
        <div class="node" data-i="2"><span class="ic">🎨</span>ruff format<small>统一排版</small></div>
        <span class="arrow" data-i="2">→</span>
        <div class="node" data-i="3"><span class="ic">🧪</span>pytest<small>跑测试</small></div>
        <span class="arrow" data-i="3">→</span>
        <div class="node" data-i="4"><span class="ic">🚀</span>合并/部署</div>
      </div>
      <div class="code" style="margin-top:6px"><span class="cm"># 一行搞定质量门禁:任意一步失败则整体失败</span>
ruff check . <span class="kw">&&</span> ruff format --check . <span class="kw">&&</span> pytest</div>
      <div class="barwrap"><div class="bar" id="cibar">0%</div></div>
      <div class="report" id="cireport"></div>
      <div class="note">
        <span class="key">lint 看代码、pytest 跑代码</span>,二者互补:
        前者保证“写得规范、没有低级错误”,后者保证“逻辑真的对”。
        都不会污染你的业务代码。
      </div>
    </section>

  </div>
  <footer>用 ← → 或按钮切换步骤 · 可点击「自动播放」· 本文件可离线打开</footer>
</div>

<script>
const scenes=[...document.querySelectorAll('.scene')];
const dotsBox=document.getElementById('dots');
let cur=0, autoTimer=null;

scenes.forEach((_,i)=>{
  const d=document.createElement('div');d.className='dot'+(i===0?' active':'');
  d.onclick=()=>go(i);dotsBox.appendChild(d);
});
const dots=[...document.querySelectorAll('.dot')];

function go(n){
  if(n<0||n>=scenes.length)return;
  scenes[cur].classList.remove('active');
  cur=n;
  scenes[cur].classList.add('active');
  dots.forEach((d,i)=>{
    d.classList.toggle('active',i===cur);
    d.classList.toggle('done',i<cur);
  });
  document.getElementById('prev').disabled=(cur===0);
  document.getElementById('next').disabled=(cur===scenes.length-1);
  runScene(cur);
}

document.getElementById('next').onclick=()=>{stopAuto();go(cur+1)};
document.getElementById('prev').onclick=()=>{stopAuto();go(cur-1)};
document.addEventListener('keydown',e=>{
  if(e.key==='ArrowRight'){stopAuto();go(cur+1)}
  if(e.key==='ArrowLeft'){stopAuto();go(cur-1)}
});

const playBtn=document.getElementById('play');
playBtn.onclick=()=>{ autoTimer?stopAuto():startAuto(); };
function startAuto(){
  playBtn.textContent='⏸ 暂停';
  autoTimer=setInterval(()=>{
    if(cur>=scenes.length-1){go(0);} else {go(cur+1);}
  },3800);
}
function stopAuto(){ if(autoTimer){clearInterval(autoTimer);autoTimer=null;playBtn.textContent='▶ 自动播放';} }

function seqOn(nodes,arrows,delay=550){
  nodes.forEach(n=>n.classList.remove('on'));
  arrows.forEach(a=>a.classList.remove('on'));
  let i=0;
  const step=()=>{
    if(i<nodes.length){nodes[i].classList.add('on'); if(arrows[i])arrows[i].classList.add('on'); i++; setTimeout(step,delay);}
  };
  step();
}

function runScene(n){
  if(n===2){
    const nodes=[...document.querySelectorAll('#lintflow .node')];
    const arrows=[...document.querySelectorAll('#lintflow .arrow')];
    seqOn(nodes,arrows);
    const scan=document.getElementById('scan1');
    scan.classList.remove('run');void scan.offsetWidth;
    setTimeout(()=>scan.classList.add('run'),900);
    const rep=document.getElementById('lintreport');
    rep.innerHTML='';
    setTimeout(()=>{
      rep.innerHTML='<span class="typed err">I001  import 未排序  ·  F841  unused 变量未使用</span>';
      requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
    },3400);
  }
  if(n===3){
    const s=document.getElementById('sorted');s.textContent='';
    const full='add, divide, is_even, multiply, subtract';
    let i=0;
    (function type(){ if(i<=full.length){s.textContent=full.slice(0,i++);setTimeout(type,40);} })();
  }
  if(n===4){
    const nodes=[...document.querySelectorAll('#testflow .node')];
    const arrows=[...document.querySelectorAll('#testflow .arrow')];
    seqOn(nodes,arrows,600);
    const box=document.getElementById('lights');box.innerHTML='';
    const total=11;
    for(let k=0;k<total;k++){const l=document.createElement('div');l.className='light';box.appendChild(l);}
    const lights=[...box.children];
    const rep=document.getElementById('testreport');rep.innerHTML='';
    setTimeout(()=>{
      lights.forEach((l,k)=>setTimeout(()=>{l.classList.add('pass');l.textContent='✓';},200*k));
      setTimeout(()=>{
        rep.innerHTML='<span class="typed ok">=== 11 passed in 0.04s === ✅</span>';
        requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
      },200*total+300);
    },1600);
  }
  if(n===5){
    const nodes=[...document.querySelectorAll('#ciflow .node')];
    const arrows=[...document.querySelectorAll('#ciflow .arrow')];
    seqOn(nodes,arrows,500);
    const bar=document.getElementById('cibar');bar.style.width='0';bar.textContent='0%';
    const rep=document.getElementById('cireport');rep.innerHTML='';
    const steps=[['ruff check',33],['ruff format',66],['pytest',100]];
    steps.forEach((st,k)=>setTimeout(()=>{bar.style.width=st[1]+'%';bar.textContent=st[0]+' ✓';},900+k*900));
    setTimeout(()=>{
      rep.innerHTML='<span class="typed ok">质量门禁全部通过 → 可以合并 🚀</span>';
      requestAnimationFrame(()=>rep.querySelector('.typed').classList.add('show'));
    },900+steps.length*900);
  }
}
/* ===== 文件树交互 ===== */
const FILES = {
"calculator.py":
`"""一个简单的计算器模块,用于演示 pytest 的基本用法。"""


def add(a, b):
    """返回两个数之和。"""
    return a + b


def subtract(a, b):
    """返回 a 减 b 的结果。"""
    return a - b


def multiply(a, b):
    """返回两个数之积。"""
    return a * b


def divide(a, b):
    """除数为 0 时抛出 ValueError。"""
    if b == 0:
        raise ValueError("除数不能为 0")
    return a / b


def is_even(n):
    """判断一个整数是否为偶数。"""
    return n % 2 == 0`,

"test_calculator.py":
`import pytest
from src.calculator import add, divide, is_even, multiply, subtract


# 1) 基本断言
def test_add():
    assert add(2, 3) == 5


# 2) 异常断言
def test_divide_by_zero():
    with pytest.raises(ValueError, match="除数不能为 0"):
        divide(1, 0)


# 3) 参数化:一份逻辑跑多组数据
@pytest.mark.parametrize("number, expected", [
    (2, True), (3, False), (0, True), (-4, True), (7, False),
])
def test_is_even(number, expected):
    assert is_even(number) == expected


# 4) fixture:提供共享数据
@pytest.fixture
def calc_data():
    return {"a": 8, "b": 2}


def test_with_fixture(calc_data):
    assert add(calc_data["a"], calc_data["b"]) == 10
    assert divide(calc_data["a"], calc_data["b"]) == 4`,

"pytest.ini":
`[pytest]
pythonpath = .          ; 把根目录加入导入路径
testpaths = tests       ; 只在 tests 下收集用例
python_files = test_*.py
python_functions = test_*
addopts = -v -ra        ; 默认参数:详细输出 + 结果摘要`,

"pyproject.toml":
`[tool.ruff]
target-version = "py39"
line-length = 100
src = ["src", "tests"]

[tool.ruff.lint]
# E/W=PEP8  F=pyflakes  I=isort  B=bugbear  UP=pyupgrade
select = ["E", "W", "F", "I", "B", "UP"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"`,

"requirements.txt":
`pytest>=8.0
ruff>=0.8`,

"README.md":
`# pytest + ruff 快速上手示例

运行测试:
    python -m pytest          运行全部
    pytest -v                 详细输出
    pytest -k "divide"        按关键字筛选
    pytest -x                 首个失败即停止

代码检查(ruff):
    ruff check .              lint 检查
    ruff check --fix .        自动修复
    ruff format .             自动格式化

一键质量门禁(CI 常用):
    ruff check . && ruff format --check . && pytest`
};

const codeView=document.getElementById('codeView');
const codeTitle=document.getElementById('codeTitle');
const tree=document.getElementById('tree');

tree.addEventListener('click',e=>{
  const row=e.target.closest('.row'); if(!row)return;
  const li=row.parentElement;
  if(li.classList.contains('dir')){
    li.classList.toggle('open');
    return;
  }
  if(li.classList.contains('file')){
    tree.querySelectorAll('.file.sel').forEach(f=>f.classList.remove('sel'));
    li.classList.add('sel');
    const name=li.dataset.file;
    codeTitle.textContent='📄 '+name;
    codeView.textContent=FILES[name]||'(无内容)';
  }
});

go(0);
</script>
</body>
</html>