brain backup 2026-09-14
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Grade 3 Learning Studio Demo
|
||||
|
||||
## Start it tomorrow
|
||||
|
||||
```bash
|
||||
cd ~/brain/projects/ckmath-interactive-pilot
|
||||
python3 server.py --port 8899
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8899` on the Mac mini. This is intentionally loopback-only: it is a private demo, not a LAN service.
|
||||
|
||||
## A 3-minute walkthrough for Alicia
|
||||
|
||||
1. **Start at the landing page.** Explain that the library is derived from the downloaded Grade 3 curriculum, not a separate subscription or AI-generated scope-and-sequence.
|
||||
2. **Use a subject filter.** Choose **Math** to show the nine Grade 3 math entries, then point out the “Open Teacher Guide” and “Open Student Reader” links.
|
||||
3. **Open CKMath Unit 1’s Teacher Guide.** It opens the original 285-page guide from inside the verified ZIP—no duplicate PDF copy is created. Explain that this is where the teacher’s learning goals, timing, prompts, materials, expected responses, and support notes live.
|
||||
4. **Return to the site and open the Interactive lesson.** Demonstrate building seven pairs of shoes, writing `7 × 2 = 14`, and giving a one-sentence explanation.
|
||||
5. **Frame the next decision.** The library and teacher-plan access can cover the whole Grade 3 collection now; we should next choose which curriculum areas deserve human-reviewed interactive sessions first.
|
||||
|
||||
## Current scope
|
||||
|
||||
- 51 downloaded Grade 3 units indexed.
|
||||
- 37 teacher-guide PDFs and 25 student-resource PDFs available on demand from their original archives.
|
||||
- One completed interactive math lesson: Unit 1, equal groups.
|
||||
- No account, analytics, AI, or learner-record storage.
|
||||
|
||||
## Not yet claimed
|
||||
|
||||
This is not yet a complete digitization of every Grade 3 lesson. It is a full source library plus a working interactive pattern. Every additional interactive lesson should be selected, authored/reviewed against its teacher guide, and checked for its individual license notice.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Grade 3 Learning Studio
|
||||
|
||||
A private, browser-only Grade 3 curriculum showcase built from the verified local Core Knowledge collection. It catalogs every downloaded Grade 3 archive, exposes original teacher-guide/student PDFs without duplicating them, and includes a 10-minute interactive equal-groups math session.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd ~/brain/projects/ckmath-interactive-pilot
|
||||
python3 server.py --port 8899
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8899`. The server is deliberately loopback-only and does not expose the curriculum on the home network.
|
||||
|
||||
See `DEMO.md` for Alicia’s short walkthrough.
|
||||
|
||||
## Privacy
|
||||
|
||||
This showcase uses no login, AI service, analytics, or learner-data storage. The original curriculum assets remain inside `/Users/adolforeyna/Downloads/CoreKnowledge_Grades_1-5_EN_ES`; the server streams a selected original PDF only after a locally generated, catalog-known material ID is requested.
|
||||
|
||||
## Source and license
|
||||
|
||||
The catalog is derived from the verified downloaded Core Knowledge Grade 3 archive set. The interactive session is adapted from *CKMath Grade 3, Unit 1: Introducing Multiplication*, Core Knowledge Foundation, Lesson 9 (“Equal Group Situations”), downloaded from [Core Knowledge](https://www.coreknowledge.org/download-free-curriculum/).
|
||||
|
||||
The sampled math source PDF carries a Creative Commons **Attribution–NonCommercial–ShareAlike 4.0 International** notice. This pilot is private and non-commercial. Any future sharing must retain attribution, the license notice, and the same ShareAlike terms. Confirm the license embedded in every additional unit before adapting it.
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isCorrectTotal, isCorrectEquation, hasReasoningEvidence } from './src/session.mjs';
|
||||
|
||||
const groups = document.querySelector('#groups');
|
||||
const addPair = document.querySelector('#add-pair');
|
||||
const buildFeedback = document.querySelector('#build-feedback');
|
||||
const totalRow = document.querySelector('#total-row');
|
||||
const total = document.querySelector('#total');
|
||||
const totalFeedback = document.querySelector('#total-feedback');
|
||||
const activity2 = document.querySelector('#activity-2');
|
||||
const activity3 = document.querySelector('#activity-3');
|
||||
const equation = document.querySelector('#equation');
|
||||
const equationFeedback = document.querySelector('#equation-feedback');
|
||||
const reasoning = document.querySelector('#reasoning');
|
||||
const reasoningFeedback = document.querySelector('#reasoning-feedback');
|
||||
|
||||
let pairsAdded = 0;
|
||||
|
||||
function feedback(element, message, kind = '') {
|
||||
element.textContent = message;
|
||||
element.className = `feedback ${kind}`;
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
groups.innerHTML = '';
|
||||
for (let i = 1; i <= 7; i += 1) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'group';
|
||||
group.innerHTML = `<span>Person ${i}</span><div class="shoes">${i <= pairsAdded ? '👟 👟' : '· ·'}</div>`;
|
||||
groups.append(group);
|
||||
}
|
||||
}
|
||||
|
||||
function reveal(activity, progressId) {
|
||||
activity.hidden = false;
|
||||
document.querySelector(progressId).classList.add('active');
|
||||
activity.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
renderGroups();
|
||||
|
||||
addPair.addEventListener('click', () => {
|
||||
if (pairsAdded < 7) pairsAdded += 1;
|
||||
renderGroups();
|
||||
if (pairsAdded === 7) {
|
||||
addPair.disabled = true;
|
||||
addPair.textContent = 'All 7 pairs are built';
|
||||
feedback(buildFeedback, 'Nice work. You built 7 equal groups of 2 shoes.', 'good');
|
||||
totalRow.hidden = false;
|
||||
total.focus();
|
||||
} else {
|
||||
feedback(buildFeedback, `${pairsAdded} of 7 pairs built. Keep going.`);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#check-total').addEventListener('click', () => {
|
||||
if (isCorrectTotal(total.value)) {
|
||||
feedback(totalFeedback, 'Yes—7 groups of 2 make 14 shoes.', 'good');
|
||||
reveal(activity2, '#progress-2');
|
||||
equation.focus();
|
||||
} else {
|
||||
feedback(totalFeedback, 'Try counting by twos: 2, 4, 6…', 'bad');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#check-equation').addEventListener('click', () => {
|
||||
if (isCorrectEquation(equation.value)) {
|
||||
feedback(equationFeedback, 'Correct. Multiplication is a quick way to name equal groups.', 'good');
|
||||
reveal(activity3, '#progress-3');
|
||||
reasoning.focus();
|
||||
} else {
|
||||
feedback(equationFeedback, 'Use the numbers 7, 2, and 14. Either order for the factors works.', 'bad');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector('#check-reasoning').addEventListener('click', () => {
|
||||
if (hasReasoningEvidence(reasoning.value)) {
|
||||
feedback(reasoningFeedback, 'Session complete! You used equal groups and multiplication to explain your answer.', 'good');
|
||||
} else {
|
||||
feedback(reasoningFeedback, 'Add how many groups there were, how many were in each group, and the total of 14.', 'bad');
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
# Grade 3 Curriculum Showcase Implementation Plan
|
||||
|
||||
> **For Hermes:** Build the showable local demo task-by-task with test-first checks.
|
||||
|
||||
**Goal:** Turn the downloaded Grade 3 Core Knowledge collection into a private local library that exposes every available unit, its student resources, and its teacher-guide PDFs, while keeping the interactive equal-groups session as a concrete lesson example for Alicia.
|
||||
|
||||
**Architecture:** A local Python server serves the existing static interactive session and a generated Grade 3 catalog. The catalog is derived directly from the verified download manifest and ZIP contents; teacher/student PDFs stay inside their original ZIP archives and are streamed only on demand, avoiding copied curriculum files. The interface uses browser-only state and no accounts or learner-data storage.
|
||||
|
||||
**Tech stack:** Python stdlib (`http.server`, `zipfile`, `json`), PyMuPDF for PDF page metadata, vanilla HTML/CSS/JavaScript, Node’s built-in test runner.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Derive a Grade 3 catalog from verified downloads
|
||||
- Add a failing test for grade filtering, subject classification, and teacher-guide detection.
|
||||
- Implement `src/catalog.mjs` pure helpers.
|
||||
- Implement `scripts/build_grade3_catalog.py` to inspect every Grade 3 archive from `download-manifest.json`, recording unit title, source archive, internal PDFs, material roles, and PDF page counts.
|
||||
- Generate `data/grade3_catalog.json`.
|
||||
|
||||
### Task 2: Stream original PDFs without copying curriculum
|
||||
- Add a failing server test for a manifest-known PDF route and an unknown/traversal-style route.
|
||||
- Implement `server.py` with static-file serving, a catalog endpoint, and a constrained ZIP-PDF route based only on IDs present in `data/grade3_catalog.json`.
|
||||
|
||||
### Task 3: Add a Grade 3 library and teacher-plan view
|
||||
- Add a failing browser-independent test for filtering by subject and selection state.
|
||||
- Extend the local UI with a Grade 3 library showing every catalogued unit, student resources, teacher guides, source/licensing notice, and one selected teacher-plan preview.
|
||||
- Link teacher guides to the constrained streaming route and retain the first interactive math session as an explicit classroom activity.
|
||||
|
||||
### Task 4: Verify the demo
|
||||
- Run catalog generation, all tests, server route checks, browser learner flow, and accessibility audit.
|
||||
- Demonstrate responsive library browsing and a teacher guide opening in the browser.
|
||||
- Stop the temporary local server after the demonstration; do not bind to the LAN or store learner data.
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="A private local Grade 3 Core Knowledge curriculum library and interactive lesson pilot.">
|
||||
<title>Grade 3 Learning Studio</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="#top" aria-label="Grade 3 Learning Studio home">Grade 3 <span>Learning Studio</span></a>
|
||||
<nav aria-label="Primary navigation"><a href="#library">Library</a><a href="#interactive-session">Interactive lesson</a></nav>
|
||||
</header>
|
||||
<main id="top">
|
||||
<section class="hero" aria-labelledby="hero-title">
|
||||
<div>
|
||||
<p class="eyebrow">Private family curriculum pilot</p>
|
||||
<h1 id="hero-title">A whole Grade 3 year—organized around real teaching.</h1>
|
||||
<p class="lede">Browse the downloaded Core Knowledge units, open their original teacher plans and student materials, then use interactive sessions for the moments where practice benefits from response and feedback.</p>
|
||||
<div class="hero-actions"><a class="button" href="#library">Explore Grade 3</a><a class="button quiet" href="#interactive-session">Try the math session</a></div>
|
||||
</div>
|
||||
<aside class="hero-note" aria-label="What this demo includes">
|
||||
<p class="note-label">Tonight’s working demo</p>
|
||||
<p><strong id="unit-count">…</strong> Grade 3 units indexed</p>
|
||||
<p><strong id="guide-count">…</strong> teacher-guide PDFs ready to open</p>
|
||||
<p><strong id="student-count">…</strong> student resources indexed</p>
|
||||
<hr>
|
||||
<p class="small">No account, AI, analytics, or learner records. The source files stay in the verified local curriculum archive.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="teacher-bridge" aria-labelledby="teacher-bridge-title">
|
||||
<div><p class="eyebrow">Teacher-led by design</p><h2 id="teacher-bridge-title">The lesson plan stays in charge.</h2></div>
|
||||
<p>Each unit keeps its original teacher guide—learning goals, materials, talk moves, expected responses, and support for misunderstandings. Interactive activities are a layer on top, not a replacement for the teacher.</p>
|
||||
</section>
|
||||
|
||||
<section id="library" class="library-section" aria-labelledby="library-title">
|
||||
<div class="section-head"><div><p class="eyebrow">The downloaded collection</p><h2 id="library-title">Grade 3 curriculum library</h2></div><p id="library-status" class="small" aria-live="polite">Loading the verified local catalog…</p></div>
|
||||
<div id="subject-filters" class="filters" role="group" aria-label="Filter units by subject"></div>
|
||||
<div id="unit-grid" class="unit-grid"></div>
|
||||
</section>
|
||||
|
||||
<section id="interactive-session" class="interactive-wrap" aria-labelledby="interactive-title">
|
||||
<div class="section-head"><div><p class="eyebrow">Interactive lesson example</p><h2 id="interactive-title">Math · Unit 1 · Equal groups</h2></div><p class="small">Based on the Unit 1 teacher-plan activity: represent equal-group situations with concrete objects or drawings.</p></div>
|
||||
<div class="progress" role="group" aria-label="Session progress"><span class="active" id="progress-1"></span><span id="progress-2"></span><span id="progress-3"></span></div>
|
||||
<section class="session" id="activity-1" aria-labelledby="activity-1-title">
|
||||
<h3 id="activity-1-title">1. Build the situation</h3>
|
||||
<p class="prompt">There are <strong>7 people</strong> wearing shoes. Each person is wearing <strong>2 shoes</strong>. Build the equal groups.</p>
|
||||
<div class="groups" id="groups" role="group" aria-label="Seven groups of shoes"></div>
|
||||
<button id="add-pair" type="button">Add a pair of shoes</button>
|
||||
<p class="feedback" id="build-feedback" aria-live="polite">Add one pair for each person.</p>
|
||||
<div class="answer-row" id="total-row" hidden><label for="total">How many shoes altogether?</label><input id="total" inputmode="numeric" autocomplete="off" aria-describedby="total-help"><button id="check-total" type="button">Check total</button></div>
|
||||
<p class="tip" id="total-help">Count by twos or add 2 seven times.</p><p class="feedback" id="total-feedback" aria-live="polite"></p>
|
||||
</section>
|
||||
<section class="session" id="activity-2" aria-labelledby="activity-2-title" hidden>
|
||||
<h3 id="activity-2-title">2. Name it with multiplication</h3><p class="prompt">Write an equation for the groups you built. You can use <strong>×</strong> or <strong>x</strong>.</p>
|
||||
<div class="answer-row"><label for="equation">Equation</label><input id="equation" autocomplete="off" placeholder="7 × 2 = 14"><button id="check-equation" type="button">Check equation</button></div><p class="feedback" id="equation-feedback" aria-live="polite"></p><p class="tip">Both 7 × 2 = 14 and 2 × 7 = 14 describe the same total.</p>
|
||||
</section>
|
||||
<section class="session" id="activity-3" aria-labelledby="activity-3-title" hidden>
|
||||
<h3 id="activity-3-title">3. Show your thinking</h3><p class="prompt">How did the groups help you know there are 14 shoes?</p><label for="reasoning">Your explanation</label><textarea id="reasoning" placeholder="I saw 7 groups of 2. I added…"></textarea><div class="answer-row"><button id="check-reasoning" type="button">Finish session</button></div><p class="feedback" id="reasoning-feedback" aria-live="polite"></p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<p class="source">Curriculum source: Core Knowledge Foundation, downloaded from <a href="https://www.coreknowledge.org/download-free-curriculum/">Core Knowledge</a>. This private, non-commercial pilot links to the original local curriculum materials and retains each source unit’s embedded license and attribution notice. Confirm the embedded license before adapting or sharing any additional unit.</p>
|
||||
</main>
|
||||
<script type="module" src="app.mjs"></script>
|
||||
<script type="module" src="library-app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { filterUnits, subjectsFor, teacherGuidesFor } from './src/library.mjs';
|
||||
|
||||
const filters = document.querySelector('#subject-filters');
|
||||
const grid = document.querySelector('#unit-grid');
|
||||
const status = document.querySelector('#library-status');
|
||||
let units = [];
|
||||
let selectedSubject = 'All';
|
||||
|
||||
function resourceLink(material) {
|
||||
return `<a class="resource-link" href="/api/pdf/${material.id}" target="_blank" rel="noopener">Open ${material.role}<span aria-hidden="true"> ↗</span></a>`;
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
filters.innerHTML = subjectsFor(units).map((subject) => `<button class="filter ${subject === selectedSubject ? 'selected' : ''}" type="button" data-subject="${subject}">${subject}</button>`).join('');
|
||||
filters.querySelectorAll('button').forEach((button) => button.addEventListener('click', () => {
|
||||
selectedSubject = button.dataset.subject;
|
||||
renderFilters();
|
||||
renderUnits();
|
||||
}));
|
||||
}
|
||||
|
||||
function renderUnits() {
|
||||
const filtered = filterUnits(units, selectedSubject);
|
||||
status.textContent = `${filtered.length} ${selectedSubject === 'All' ? 'units' : selectedSubject + ' units'} shown`;
|
||||
grid.innerHTML = filtered.map((unit) => {
|
||||
const guides = teacherGuidesFor(unit);
|
||||
const student = unit.materials.filter((material) => material.role === 'Student Reader');
|
||||
const resources = [...guides, ...student].slice(0, 3).map(resourceLink).join('');
|
||||
return `<article class="unit-card"><p class="unit-subject">${unit.subject}</p><h3>${unit.title.replace(/^Grade 3\s*/, '')}</h3><p class="material-count">${guides.length ? `${guides.length} teacher plan${guides.length === 1 ? '' : 's'}` : 'Teacher plan not identified'} · ${student.length} student resource${student.length === 1 ? '' : 's'}</p>${resources ? `<div class="resource-links">${resources}</div>` : '<p class="small">Open the original archive source for supporting materials.</p>'}<a class="source-link" href="${unit.source_url}" target="_blank" rel="noopener">Core Knowledge source ↗</a></article>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadLibrary() {
|
||||
try {
|
||||
const response = await fetch('/api/catalog', { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('catalog unavailable');
|
||||
const catalog = await response.json();
|
||||
units = catalog.units;
|
||||
document.querySelector('#unit-count').textContent = catalog.unit_count;
|
||||
document.querySelector('#guide-count').textContent = catalog.teacher_guide_count;
|
||||
document.querySelector('#student-count').textContent = catalog.student_material_count;
|
||||
renderFilters();
|
||||
renderUnits();
|
||||
} catch {
|
||||
status.textContent = 'The local catalog could not load. Start the private Grade 3 demo server.';
|
||||
}
|
||||
}
|
||||
|
||||
loadLibrary();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "ckmath-interactive-pilot",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a local-only Grade 3 catalog from verified Core Knowledge archives."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROLE_ORDER = {'Student Reader': 0, 'Teacher Guide': 1, 'Reference': 2}
|
||||
|
||||
|
||||
def is_grade_three_source(item):
|
||||
title = str(item.get('title', ''))
|
||||
filename = str(item.get('filename', ''))
|
||||
return bool(re.search(r'\bGrade 3\b', title, re.I) or re.search(r'(?:^|[_-])G3(?:[_-]|$)', filename, re.I))
|
||||
|
||||
|
||||
def classify_unit(filename):
|
||||
if re.search(r'CKMath', filename, re.I):
|
||||
return 'Math'
|
||||
if re.search(r'CKSci', filename, re.I):
|
||||
return 'Science'
|
||||
if re.search(r'CKLA', filename, re.I):
|
||||
return 'Language Arts'
|
||||
if re.search(r'CKHG|CKIYS|LABB_CKHG', filename, re.I):
|
||||
return 'History & Geography'
|
||||
if re.search(r'CKMusic|CKVA', filename, re.I):
|
||||
return 'Arts'
|
||||
return 'Other'
|
||||
|
||||
|
||||
def role_for_pdf(filename):
|
||||
if re.search(r'(?:^|[_-])(TG|TeacherGuide|Teacher[_ -]?Guide)(?:[_-]|\.)', filename, re.I):
|
||||
return 'Teacher Guide'
|
||||
if re.search(r'(?:^|[_-])(SR|StudentReader|Student[_ -]?Reader|ActivityBook)(?:[_-]|\.)', filename, re.I):
|
||||
return 'Student Reader'
|
||||
return 'Reference'
|
||||
|
||||
|
||||
def material_id(archive_name, internal_path):
|
||||
raw = '%s\0%s' % (archive_name, internal_path)
|
||||
return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:20]
|
||||
|
||||
|
||||
def pdf_page_count(archive_path, internal_path):
|
||||
try:
|
||||
import fitz
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
document = fitz.open(stream=archive.read(internal_path), filetype='pdf')
|
||||
count = document.page_count
|
||||
document.close()
|
||||
return count
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def inspect_archive(archive_path, include_page_counts=False):
|
||||
materials = []
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for info in archive.infolist():
|
||||
internal_path = info.filename
|
||||
if internal_path.startswith('__MACOSX/') or not internal_path.lower().endswith('.pdf'):
|
||||
continue
|
||||
filename = Path(internal_path).name
|
||||
material = {
|
||||
'id': material_id(archive_path.name, internal_path),
|
||||
'filename': filename,
|
||||
'internal_path': internal_path,
|
||||
'role': role_for_pdf(filename),
|
||||
}
|
||||
if include_page_counts:
|
||||
material['pages'] = pdf_page_count(archive_path, internal_path)
|
||||
materials.append(material)
|
||||
materials.sort(key=lambda item: (ROLE_ORDER[item['role']], item['filename'].lower()))
|
||||
return materials
|
||||
|
||||
|
||||
def build_catalog(manifest_path, downloads_dir, include_page_counts=False):
|
||||
entries = json.loads(Path(manifest_path).read_text())
|
||||
units = []
|
||||
for item in entries:
|
||||
if item.get('type') != 'zip' or not is_grade_three_source(item):
|
||||
continue
|
||||
archive_path = Path(downloads_dir) / item['filename']
|
||||
if not archive_path.is_file():
|
||||
continue
|
||||
materials = inspect_archive(archive_path, include_page_counts)
|
||||
units.append({
|
||||
'id': hashlib.sha256(item['filename'].encode('utf-8')).hexdigest()[:16],
|
||||
'title': item['title'],
|
||||
'filename': item['filename'],
|
||||
'source_url': item['source'],
|
||||
'subject': classify_unit(item['filename']),
|
||||
'materials': materials,
|
||||
'teacher_guide_count': sum(material['role'] == 'Teacher Guide' for material in materials),
|
||||
'student_material_count': sum(material['role'] == 'Student Reader' for material in materials),
|
||||
})
|
||||
units.sort(key=lambda item: (item['subject'], item['title'].lower()))
|
||||
return {
|
||||
'grade': 3,
|
||||
'unit_count': len(units),
|
||||
'teacher_guide_count': sum(unit['teacher_guide_count'] for unit in units),
|
||||
'student_material_count': sum(unit['student_material_count'] for unit in units),
|
||||
'units': units,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--manifest', type=Path, required=True)
|
||||
parser.add_argument('--downloads-dir', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--page-counts', action='store_true')
|
||||
args = parser.parse_args()
|
||||
catalog = build_catalog(args.manifest, args.downloads_dir, args.page_counts)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(catalog, indent=2) + '\n')
|
||||
print(json.dumps({
|
||||
'output': str(args.output),
|
||||
'units': catalog['unit_count'],
|
||||
'teacher_guides': catalog['teacher_guide_count'],
|
||||
'student_materials': catalog['student_material_count'],
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Private loopback demo server for the Grade 3 curriculum showcase."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from http import HTTPStatus
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DEFAULT_DOWNLOADS = Path.home() / 'Downloads' / 'CoreKnowledge_Grades_1-5_EN_ES'
|
||||
sys.path.insert(0, str(ROOT / 'src'))
|
||||
from server_catalog import lookup_material, material_index
|
||||
|
||||
|
||||
class ShowcaseHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, downloads_dir=None, **kwargs):
|
||||
self.downloads_dir = Path(downloads_dir or DEFAULT_DOWNLOADS).resolve()
|
||||
super().__init__(*args, directory=str(ROOT), **kwargs)
|
||||
|
||||
def catalog(self):
|
||||
return json.loads((ROOT / 'data' / 'grade3_catalog.json').read_text())
|
||||
|
||||
def send_json(self, data):
|
||||
body = json.dumps(data).encode('utf-8')
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header('Content-Type', 'application/json; charset=utf-8')
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.send_header('Cache-Control', 'no-store')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_pdf(self, material_id):
|
||||
if not re.fullmatch(r'[0-9a-f]{20}', material_id):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
pair = lookup_material(material_index(self.catalog()), material_id)
|
||||
if pair is None:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
archive_name, internal_path = pair
|
||||
archive_path = (self.downloads_dir / archive_name).resolve()
|
||||
if archive_path.parent != self.downloads_dir:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
try:
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
content = archive.read(internal_path)
|
||||
except (FileNotFoundError, KeyError, zipfile.BadZipFile):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
filename = Path(internal_path).name
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header('Content-Type', mimetypes.guess_type(filename)[0] or 'application/pdf')
|
||||
self.send_header('Content-Length', str(len(content)))
|
||||
self.send_header('Content-Disposition', 'inline; filename="%s"' % filename.replace('"', ''))
|
||||
self.send_header('Cache-Control', 'private, no-store')
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == '/api/catalog':
|
||||
self.send_json(self.catalog())
|
||||
return
|
||||
if path.startswith('/api/pdf/'):
|
||||
self.send_pdf(unquote(path[len('/api/pdf/'):]))
|
||||
return
|
||||
super().do_GET()
|
||||
|
||||
|
||||
def run(port, downloads_dir):
|
||||
def handler(*args, **kwargs):
|
||||
return ShowcaseHandler(*args, downloads_dir=downloads_dir, **kwargs)
|
||||
server = ThreadingHTTPServer(('127.0.0.1', port), handler)
|
||||
print('Serving Grade 3 showcase at http://127.0.0.1:%s' % port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, default=8899)
|
||||
parser.add_argument('--downloads-dir', type=Path, default=DEFAULT_DOWNLOADS)
|
||||
args = parser.parse_args()
|
||||
run(args.port, args.downloads_dir)
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
export function isGradeThreeSource(source) {
|
||||
return /\bGrade 3\b/i.test(source.title ?? '') || /(?:^|[_-])G3(?:[_-]|$)/i.test(source.filename ?? '');
|
||||
}
|
||||
|
||||
export function classifyUnit(filename) {
|
||||
const name = String(filename);
|
||||
if (/CKMath/i.test(name)) return 'Math';
|
||||
if (/CKSci/i.test(name)) return 'Science';
|
||||
if (/CKLA/i.test(name)) return 'Language Arts';
|
||||
if (/CKHG|CKIYS|LABB_CKHG/i.test(name)) return 'History & Geography';
|
||||
if (/CKMusic|CKVA/i.test(name)) return 'Arts';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
export function roleForPdf(filename) {
|
||||
const name = String(filename);
|
||||
if (/(?:^|[_-])(TG|TeacherGuide|Teacher[_ -]?Guide)(?:[_-]|\.)/i.test(name)) return 'Teacher Guide';
|
||||
if (/(?:^|[_-])(SR|StudentReader|Student[_ -]?Reader|ActivityBook)(?:[_-]|\.)/i.test(name)) return 'Student Reader';
|
||||
return 'Reference';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function subjectsFor(units) {
|
||||
return ['All', ...new Set(units.map((unit) => unit.subject).filter(Boolean))].sort((a, b) => {
|
||||
if (a === 'All') return -1;
|
||||
if (b === 'All') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterUnits(units, subject) {
|
||||
return subject === 'All' ? units : units.filter((unit) => unit.subject === subject);
|
||||
}
|
||||
|
||||
export function teacherGuidesFor(unit) {
|
||||
return (unit.materials ?? []).filter((material) => material.role === 'Teacher Guide');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
def material_index(catalog):
|
||||
"""Map only generated catalog IDs to their exact archive member paths."""
|
||||
index = {}
|
||||
for unit in catalog.get('units', []):
|
||||
archive_name = unit.get('filename')
|
||||
if not archive_name:
|
||||
continue
|
||||
for material in unit.get('materials', []):
|
||||
material_id = material.get('id')
|
||||
internal_path = material.get('internal_path')
|
||||
if material_id and internal_path:
|
||||
index[material_id] = (archive_name, internal_path)
|
||||
return index
|
||||
|
||||
|
||||
def lookup_material(index, material_id):
|
||||
"""Return a declared pair or None; request input is never a filesystem path."""
|
||||
return index.get(material_id)
|
||||
@@ -0,0 +1,16 @@
|
||||
export function isCorrectTotal(value) {
|
||||
return Number(String(value).trim()) === 14;
|
||||
}
|
||||
|
||||
export function isCorrectEquation(value) {
|
||||
const numbers = String(value).match(/\d+/g)?.map(Number) ?? [];
|
||||
if (numbers.length !== 3) return false;
|
||||
const [first, second, product] = numbers;
|
||||
return product === 14 && ((first === 7 && second === 2) || (first === 2 && second === 7));
|
||||
}
|
||||
|
||||
export function hasReasoningEvidence(value) {
|
||||
const text = String(value).toLowerCase();
|
||||
const strategy = /group|each|repeated|add|plus/.test(text);
|
||||
return strategy && /\b14\b/.test(text);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
:root { --ink:#142737; --muted:#576a75; --paper:#f7f4ed; --card:#fffdfa; --line:#ded7c7; --blue:#156d92; --blue-deep:#0e526f; --mint:#dceee9; --gold:#edb950; --good:#13765a; --bad:#a13d34; }
|
||||
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-height:100vh; color:var(--ink); background:var(--paper); font-family:"Avenir Next",Avenir,ui-rounded,system-ui,sans-serif; }
|
||||
a { color:var(--blue-deep); } .site-header { width:min(100% - 2rem,1160px); margin:auto; padding:1.15rem 0; display:flex; align-items:center; justify-content:space-between; gap:1rem; } .brand { color:var(--ink); text-decoration:none; font-weight:850; letter-spacing:-.04em; font-size:1.17rem; } .brand span { color:var(--blue); } nav { display:flex; gap:1.2rem; } nav a { color:var(--muted); text-decoration:none; font-size:.92rem; font-weight:700; }
|
||||
main { width:min(100% - 2rem,1160px); margin:auto; } .hero { display:grid; grid-template-columns:1.5fr .78fr; gap:clamp(1.5rem,6vw,6rem); align-items:end; padding:clamp(3rem,8vw,7rem) 0 4rem; border-bottom:1px solid var(--line); } .eyebrow { margin:0 0 .6rem; color:var(--blue-deep); font-size:.73rem; font-weight:850; letter-spacing:.12em; text-transform:uppercase; } h1,h2,h3 { font-family:Georgia,"Times New Roman",serif; letter-spacing:-.045em; } h1 { max-width:13ch; margin:0; font-size:clamp(3.1rem,7vw,6.3rem); line-height:.92; } h2 { margin:0; font-size:clamp(2rem,4vw,3rem); line-height:1; } h3 { margin:.2rem 0 .8rem; font-size:1.45rem; line-height:1.05; } .lede { max-width:58ch; color:var(--muted); font-size:1.08rem; line-height:1.6; } .hero-actions { display:flex; flex-wrap:wrap; gap:.65rem; margin-top:1.5rem; } .button,button { appearance:none; border:0; border-radius:.68rem; padding:.8rem 1rem; color:white; background:var(--blue); cursor:pointer; text-decoration:none; font:inherit; font-weight:800; } .button:hover,button:hover { background:var(--blue-deep); } .button.quiet { color:var(--blue-deep); background:#dceef3; } .hero-note { border:1px solid var(--line); border-radius:1rem; padding:1.2rem 1.3rem; background:var(--card); box-shadow:0 1rem 2.4rem #283d4012; } .hero-note p { margin:.55rem 0; } .hero-note strong { font-family:Georgia,serif; color:var(--blue-deep); font-size:1.5rem; } .note-label { color:var(--muted); font-size:.8rem; font-weight:800; text-transform:uppercase; letter-spacing:.09em; } hr { border:0; border-top:1px solid var(--line); margin:1rem 0; }
|
||||
.teacher-bridge { display:grid; grid-template-columns:.8fr 1.2fr; gap:2rem; padding:4rem 0; } .teacher-bridge>p { margin:0; color:var(--muted); font-size:1.06rem; line-height:1.65; } .library-section,.interactive-wrap { padding:4rem 0; border-top:1px solid var(--line); } .section-head { display:flex; justify-content:space-between; align-items:end; gap:1.5rem; margin-bottom:1.5rem; } .small { color:var(--muted); font-size:.88rem; line-height:1.5; } .filters { display:flex; flex-wrap:wrap; gap:.45rem; margin:1.25rem 0 1.5rem; } .filter { padding:.52rem .75rem; border:1px solid #cbd7d9; color:var(--blue-deep); background:transparent; font-size:.88rem; } .filter.selected { color:white; background:var(--blue-deep); border-color:var(--blue-deep); }
|
||||
.unit-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:.85rem; } .unit-card { display:flex; min-height:235px; flex-direction:column; padding:1.15rem; border:1px solid var(--line); border-radius:.95rem; background:var(--card); } .unit-subject { margin:0; color:var(--blue-deep); font-size:.75rem; font-weight:850; letter-spacing:.08em; text-transform:uppercase; } .material-count { margin:0 0 .9rem; color:var(--muted); font-size:.86rem; line-height:1.4; } .resource-links { display:grid; gap:.35rem; margin-top:auto; } .resource-link { width:fit-content; color:var(--blue-deep); font-size:.9rem; font-weight:800; } .source-link { margin-top:.85rem; color:var(--muted); font-size:.76rem; }
|
||||
.interactive-wrap { padding-bottom:2rem; max-width:790px; } .progress { display:flex; gap:.45rem; margin:2rem 0 1.2rem; } .progress span { height:.4rem; flex:1; border-radius:2rem; background:#d8e0e2; } .progress span.active { background:var(--blue); } .session { border:1px solid var(--line); border-radius:1.1rem; background:var(--card); box-shadow:0 1.2rem 3rem #42381610; padding:clamp(1.25rem,4vw,2rem); } .session + .session { margin-top:1rem; } .session h3 { font-size:1.75rem; } .prompt { margin:.9rem 0 1.25rem; font-size:1.1rem; line-height:1.5; } .groups { display:grid; grid-template-columns:repeat(auto-fit,minmax(105px,1fr)); gap:.65rem; margin:1.1rem 0; } .group { min-height:88px; border:2px dashed #b7d0dc; border-radius:.85rem; padding:.65rem; background:#f3fbfd; text-align:center; } .group span { display:block; color:var(--muted); font-size:.78rem; font-weight:700; } .shoes { min-height:42px; padding-top:.45rem; font-size:1.45rem; letter-spacing:.08em; } .answer-row { display:flex; gap:.65rem; flex-wrap:wrap; align-items:center; margin-top:1.15rem; } input,textarea { width:min(100%,320px); border:1px solid #afbac1; border-radius:.65rem; background:white; color:var(--ink); font:inherit; padding:.78rem; } textarea { min-height:112px; width:100%; resize:vertical; } button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible { outline:3px solid var(--gold); outline-offset:3px; } .feedback { min-height:1.5rem; margin:.8rem 0 0; font-weight:700; } .feedback.good { color:var(--good); } .feedback.bad { color:var(--bad); } .tip { color:var(--muted); font-size:.94rem; line-height:1.45; } [hidden] { display:none!important; } .source { max-width:850px; margin:2rem 0 3.5rem; color:var(--muted); font-size:.8rem; line-height:1.5; }
|
||||
@media (max-width:800px) { .hero,.teacher-bridge { grid-template-columns:1fr; } .hero { padding-top:3.5rem; } .unit-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:520px) { .site-header,main { width:min(100% - 1.2rem,1160px); } nav { gap:.8rem; } .unit-grid { grid-template-columns:1fr; } .section-head { display:block; } .section-head .small { margin-top:.8rem; } h1 { font-size:3.35rem; } .session { padding:1.15rem; } .answer-row input { width:100%; } }
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classifyUnit, roleForPdf, isGradeThreeSource } from '../src/catalog.mjs';
|
||||
|
||||
test('keeps a Grade 3 unit from the verified source list', () => {
|
||||
assert.equal(isGradeThreeSource({ title: 'Grade 3 CK Math: Unit 1', filename: 'CKMath_G3U1.zip' }), true);
|
||||
assert.equal(isGradeThreeSource({ title: 'Grade 2 CK Math: Unit 1', filename: 'CKMath_G2U1.zip' }), false);
|
||||
});
|
||||
|
||||
test('classifies major Grade 3 curriculum families', () => {
|
||||
assert.equal(classifyUnit('CKMath_G3U1_IntroducingMultiplication.zip'), 'Math');
|
||||
assert.equal(classifyUnit('CKSci_G3_U1_InvestigatingForces.zip'), 'Science');
|
||||
assert.equal(classifyUnit('CKLA_G3_Unit-1.zip'), 'Language Arts');
|
||||
assert.equal(classifyUnit('CKHG_G3_U1_WorldRivers.zip'), 'History & Geography');
|
||||
});
|
||||
|
||||
test('labels teacher guides and student resources without exposing arbitrary paths', () => {
|
||||
assert.equal(roleForPdf('CKMath_G3U1_IntroducingMultiplication_TG_W2.pdf'), 'Teacher Guide');
|
||||
assert.equal(roleForPdf('CKMath_G3U1_IntroducingMultiplication_SR_W2.pdf'), 'Student Reader');
|
||||
assert.equal(roleForPdf('CoreKnowledge_CurriculumSeries_CCL_TermsOfUse_2024_W1.pdf'), 'Reference');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterUnits, subjectsFor, teacherGuidesFor } from '../src/library.mjs';
|
||||
|
||||
const units = [
|
||||
{ subject: 'Math', title: 'Grade 3 Math', materials: [{ role: 'Teacher Guide', id: 'math-guide' }] },
|
||||
{ subject: 'Science', title: 'Grade 3 Science', materials: [{ role: 'Student Reader', id: 'science-reader' }] },
|
||||
{ subject: 'Math', title: 'Math Literature', materials: [] },
|
||||
];
|
||||
|
||||
test('lists available subjects with All first', () => {
|
||||
assert.deepEqual(subjectsFor(units), ['All', 'Math', 'Science']);
|
||||
});
|
||||
|
||||
test('filters units by selected subject', () => {
|
||||
assert.deepEqual(filterUnits(units, 'Math').map((unit) => unit.title), ['Grade 3 Math', 'Math Literature']);
|
||||
assert.equal(filterUnits(units, 'All').length, 3);
|
||||
});
|
||||
|
||||
test('selects only a unit’s teacher-plan PDFs', () => {
|
||||
assert.deepEqual(teacherGuidesFor(units[0]), [{ role: 'Teacher Guide', id: 'math-guide' }]);
|
||||
assert.deepEqual(teacherGuidesFor(units[1]), []);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isCorrectTotal,
|
||||
isCorrectEquation,
|
||||
hasReasoningEvidence,
|
||||
} from '../src/session.mjs';
|
||||
|
||||
test('accepts the total for seven groups of two', () => {
|
||||
assert.equal(isCorrectTotal('14'), true);
|
||||
});
|
||||
|
||||
test('rejects an incorrect total for seven groups of two', () => {
|
||||
assert.equal(isCorrectTotal('13'), false);
|
||||
});
|
||||
|
||||
test('accepts an equivalent multiplication equation', () => {
|
||||
assert.equal(isCorrectEquation('2 × 7 = 14'), true);
|
||||
});
|
||||
|
||||
test('requires a learner explanation to name groups or repeated addition and the total', () => {
|
||||
assert.equal(hasReasoningEvidence('There are 7 groups of 2, so there are 14 shoes.'), true);
|
||||
assert.equal(hasReasoningEvidence('It is 14.'), false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).parents[1] / 'scripts' / 'build_grade3_catalog.py'
|
||||
spec = importlib.util.spec_from_file_location('catalog_builder', MODULE_PATH)
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
|
||||
|
||||
class GradeThreeCatalogTest(unittest.TestCase):
|
||||
def test_build_catalog_keeps_grade_three_archives_and_labels_pdfs(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
archive = root / 'CKMath_G3U1.zip'
|
||||
with zipfile.ZipFile(archive, 'w') as zf:
|
||||
zf.writestr('unit/CKMath_G3U1_TG.pdf', b'not-a-real-pdf')
|
||||
zf.writestr('unit/CKMath_G3U1_SR.pdf', b'not-a-real-pdf')
|
||||
zf.writestr('__MACOSX/unit/._ignored.pdf', b'ignored')
|
||||
manifest = root / 'download-manifest.json'
|
||||
manifest.write_text(json.dumps([
|
||||
{'title': 'Grade 3 CKMath: Unit 1', 'filename': archive.name, 'source': 'https://example.test/g3.zip', 'type': 'zip'},
|
||||
{'title': 'Grade 2 CKMath: Unit 1', 'filename': 'CKMath_G2U1.zip', 'source': 'https://example.test/g2.zip', 'type': 'zip'},
|
||||
]))
|
||||
|
||||
catalog = builder.build_catalog(manifest, root)
|
||||
|
||||
self.assertEqual(catalog['unit_count'], 1)
|
||||
unit = catalog['units'][0]
|
||||
self.assertEqual(unit['subject'], 'Math')
|
||||
self.assertEqual([material['role'] for material in unit['materials']], ['Student Reader', 'Teacher Guide'])
|
||||
self.assertEqual(unit['teacher_guide_count'], 1)
|
||||
self.assertEqual(unit['student_material_count'], 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
import importlib.util
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
MODULE_PATH = Path(__file__).parents[1] / 'src' / 'server_catalog.py'
|
||||
spec = importlib.util.spec_from_file_location('server_catalog', MODULE_PATH)
|
||||
server_catalog = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(server_catalog)
|
||||
|
||||
|
||||
class ServerCatalogTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.catalog = {
|
||||
'units': [{
|
||||
'filename': 'CKMath_G3U1.zip',
|
||||
'materials': [{'id': 'guide-1', 'internal_path': 'unit/guide.pdf', 'filename': 'guide.pdf'}],
|
||||
}]
|
||||
}
|
||||
|
||||
def test_known_material_id_resolves_to_its_declared_zip_member(self):
|
||||
index = server_catalog.material_index(self.catalog)
|
||||
self.assertEqual(server_catalog.lookup_material(index, 'guide-1'), ('CKMath_G3U1.zip', 'unit/guide.pdf'))
|
||||
|
||||
def test_unknown_or_path_like_id_is_not_resolved(self):
|
||||
index = server_catalog.material_index(self.catalog)
|
||||
self.assertIsNone(server_catalog.lookup_material(index, '../../etc/passwd'))
|
||||
self.assertIsNone(server_catalog.lookup_material(index, 'not-in-the-catalog'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user