diff --git a/.gitignore b/.gitignore index ff78073..a7526e9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,8 @@ old/ # config/ data/cache/ data/electron/ -#data/nucleon/ +data/nucleon/ +!data/nucleon/test* data/orbital/ AGENTS.md diff --git a/README.md b/README.md index ac37df2..48674bf 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ "潜进" 软件组项目包含多个子项目 此仓库包含了 "潜进" 项目的核心和基于 Textual 的基本用户界面的实现 -## 开发计划 -0.0.x: 简易调度器实现与最小原型. -0.1.x: 命令行操作的调度器. -0.2.x: 使用 Textual 构建富文本终端用户界面, 项目可行性验证, 采用 SM-2 原始算法, 评估方式为用户自评估的原型. -0.3.x: 简单的多文件项目, 创建了记忆内容/算法数据结构, 基于 SM-2 改进算法的自动复习测评评估. 重点设计古诗文记忆理解功能, 以及 TUI 界面实现, 简单的 TTS 集成. -0.4.x (当前): 使用模块管理解耦设计, 增加文档与类型标注, 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现, 支持其他调度算法模块 (SM-2, FSRS) 与谜题模块, 采用日志调试, 更新文件格式, 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控, 更佳的用户数据处理, 加入模块化扩展集成, 将算法数据格式换为 json 提高性能, 采用 provider-service 抽象架构, 支持切换服务提供者, 整体兼容性改进. -下一步? -使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务... +## 开发进程 +- 0.0.x: 简易调度器实现与最小原型. +- 0.1.x: 命令行操作的调度器. +- 0.2.x: 使用 Textual 构建富文本终端用户界面, 项目可行性验证, 采用 SM-2 原始算法, 评估方式为用户自评估的原型. +- 0.3.x: 简单的多文件项目, 创建了记忆内容/算法数据结构, 基于 SM-2 改进算法的自动复习测评评估. 重点设计古诗文记忆理解功能, 以及 TUI 界面实现, 简单的 TTS 集成. +- 0.4.x: 使用模块管理解耦设计, 增加文档与类型标注, 采用上下文设计模式的隐式依赖注入与遵从 IoC, 注册器设计的算法与功能实现, 支持其他调度算法模块 (SM-2, FSRS) 与谜题模块, 采用日志调试, 更新文件格式, 引入动态数据模式(宏驱动的动态内容生成), 与基于文件的策略调控, 更佳的用户数据处理, 加入模块化扩展集成, 将算法数据格式换为 json 提高性能, 采用 provider-service 抽象架构, 支持切换服务提供者, 整体兼容性改进. +> 下一步? +> 使用 Flutter 构建酷酷的现代化前端, 增加云同步/文档源服务... ## 特性 @@ -84,55 +84,6 @@ python -m heurams.interface 配置文件位于 `config/config.toml`(相对于工作目录)。如果不存在,会使用内置的默认配置。 -### 创建配置文件 -复制示例配置: -```bash -cp -r config.example/config.toml config/ -``` - -然后编辑 `config/config.toml` 以符合你的需求。 - -### 主要配置项 -```toml -# 调试设置 -persist_to_file = 1 # 是否将更改保存到文件 -daystamp_override = -1 # 覆盖日戳(测试用) -timestamp_override = -1 # 覆盖时间戳(测试用) -quick_pass = 0 # 一键通过模式 - -# 每个会话默认新记忆单元数量 -tasked_number = 8 - -# 时区偏移(秒),用于日戳计算 -timezone_offset = +28800 # 中国标准时间 (UTC+8) - -# 谜题默认配置 -[puzzles.mcq] -max_riddles_num = 2 # 选择题最大谜题数 - -[puzzles.cloze] -min_denominator = 3 # 填空题最小分母 - -# 路径配置(相对于工作目录或绝对路径) -[paths] -nucleon_dir = "./data/nucleon" -electron_dir = "./data/electron" -orbital_dir = "./data/orbital" -cache_dir = "./data/cache" -template_dir = "./data/template" - -# 服务提供者配置 -[services] -audio = "playsound" # 可选: playsound, termux -tts = "edgetts" # 可选: edgetts -llm = "openai" # 可选: openai - -# OpenAI 兼容 LLM 配置 -[providers.llm.openai] -url = "" # API 端点 URL -key = "" # API 密钥 -``` - ## 项目结构 ### 架构图 @@ -183,6 +134,7 @@ graph TB TUI --> AudioService TUI --> TTSService TUI --> OtherServices + Config --> Files Config --> Context AudioService --> AudioProvider TTSService --> TTSProvider @@ -192,7 +144,6 @@ graph TB Reactor --> Puzzles Particles --> Files Algorithms --> Files - Config --> Files ``` ### 目录结构 diff --git a/config.example/config.toml b/config.example/config.toml index 9367cb6..a7d9029 100644 --- a/config.example/config.toml +++ b/config.example/config.toml @@ -6,10 +6,10 @@ daystamp_override = -1 timestamp_override = -1 # [调试] 一键通过 -quick_pass = 0 +quick_pass = 1 # 对于每个项目的默认新记忆原子数量 -tasked_number = 8 +scheduled_num = 8 # UTC 时间戳修正 仅用于 UNIX 日时间戳的生成修正, 单位为秒 timezone_offset = +28800 # 中国标准时间 (UTC+8) diff --git a/pyproject.toml b/pyproject.toml index 5b0421a..a3b3e4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,22 @@ build-backend = "setuptools.build_meta" name = "heurams" version = "0.4.0" description = "Heuristic Assisted Memory Scheduler" +license = {file = "LICENSE"} +classifiers = [ + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Operating System :: OS Independent", + "Topic :: Education", + "Intended Audience :: Education", +] +keywords = ["spaced-repetition", "memory", "learning", "tui", "textual", "flashcards", "education"] +dependencies = [ + "bidict==0.23.1", + "playsound==1.2.2", + "textual==5.3.0", + "toml==0.10.2", +] +readme = "README.md" [tool.setuptools.packages.find] -where = ["src"] \ No newline at end of file +where = ["src"] diff --git a/requirements.txt b/requirements.txt index 2b54f44..24f6e4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ bidict==0.23.1 playsound==1.2.2 -textual==6.9.0 +textual==5.3.0 toml==0.10.2 diff --git a/src/heurams/interface/__main__.py b/src/heurams/interface/__main__.py index 6bbe0bd..a14bf79 100644 --- a/src/heurams/interface/__main__.py +++ b/src/heurams/interface/__main__.py @@ -79,4 +79,8 @@ if is_subdir(Path(rootdir), Path(os.getcwd())): environment_check() app = HeurAMSApp() -app.run() +if __name__ == "__main__": + app.run() + +def main(): + app.run() diff --git a/src/heurams/interface/screens/about.py b/src/heurams/interface/screens/about.py index ac3ba7f..c922b82 100644 --- a/src/heurams/interface/screens/about.py +++ b/src/heurams/interface/screens/about.py @@ -88,9 +88,6 @@ class AboutScreen(Screen): def action_go_back(self): self.app.pop_screen() - def action_quit_app(self): - self.app.exit() - def on_button_pressed(self, event) -> None: event.stop() if event.button.id == "back_button": diff --git a/src/heurams/interface/screens/dashboard.py b/src/heurams/interface/screens/dashboard.py index 6721c5f..f1aefb1 100644 --- a/src/heurams/interface/screens/dashboard.py +++ b/src/heurams/interface/screens/dashboard.py @@ -113,7 +113,7 @@ class DashboardScreen(Screen): return selected_label = event.item.query_one(Label) - if "未找到任何 .toml 文件" in str(selected_label.renderable): + if "未找到任何 .toml 文件" in str(selected_label.renderable): # type: ignore return selected_filename = pathlib.Path( diff --git a/src/heurams/interface/screens/memorizor.py b/src/heurams/interface/screens/memorizor.py index 3c51647..10642d6 100644 --- a/src/heurams/interface/screens/memorizor.py +++ b/src/heurams/interface/screens/memorizor.py @@ -25,7 +25,7 @@ logger = get_logger(__name__) class MemScreen(Screen): BINDINGS = [ ("q", "pop_screen", "返回"), - ("p", "prev", "复习上一个"), + # ("p", "prev", "复习上一个"), ("d", "toggle_dark", ""), ("v", "play_voice", "朗读"), ("0,1,2,3", "app.push_screen('about')", ""), @@ -91,6 +91,7 @@ class MemScreen(Screen): progress_widget.update(self._get_progress_text()) # type: ignore def load_puzzle(self): + self.atom: pt.Atom = self.procession.current_atom container = self.query_one("#puzzle-container") for i in container.children: i.remove() @@ -108,6 +109,8 @@ class MemScreen(Screen): event.stop() def watch_rating(self, old_rating, new_rating) -> None: + if self.procession == 0: + return if new_rating == -1: return forwards = 1 if new_rating >= 4 else 0 @@ -131,6 +134,9 @@ class MemScreen(Screen): self.procession.append() self.update_display() + def action_quick_pass(self): + self.rating = 5 + def action_play_voice(self): """朗读当前内容""" pass diff --git a/src/heurams/interface/screens/nucreator.py b/src/heurams/interface/screens/nucreator.py index cedec3f..871c8fa 100644 --- a/src/heurams/interface/screens/nucreator.py +++ b/src/heurams/interface/screens/nucreator.py @@ -20,7 +20,7 @@ from heurams.context import config_var class NucleonCreatorScreen(Screen): BINDINGS = [("q", "go_back", "返回")] - + SUB_TITLE = "单元集创建向导" def __init__(self) -> None: super().__init__(name=None, id=None, classes=None) diff --git a/src/heurams/interface/screens/precache.py b/src/heurams/interface/screens/precache.py index f6b6899..7bbe7a4 100644 --- a/src/heurams/interface/screens/precache.py +++ b/src/heurams/interface/screens/precache.py @@ -28,7 +28,7 @@ class PrecachingScreen(Screen): nucleons (list): 可选列表, 仅包含 Nucleon 对象 desc (list): 可选字符串, 包含对此次调用的文字描述 """ - + SUB_TITLE = "缓存管理器" BINDINGS = [("q", "go_back", "返回")] def __init__(self, nucleons: list = [], desc: str = ""): diff --git a/src/heurams/interface/screens/preparation.py b/src/heurams/interface/screens/preparation.py index a850e87..8d760e1 100644 --- a/src/heurams/interface/screens/preparation.py +++ b/src/heurams/interface/screens/preparation.py @@ -10,10 +10,15 @@ from textual.widgets import ( ) from textual.containers import ScrollableContainer from textual.screen import Screen - +from heurams.context import config_var import heurams.kernel.particles as pt import heurams.services.hasher as hasher from heurams.context import * +from textual.reactive import reactive +from textual.widget import Widget +from heurams.services.logger import get_logger + +logger = get_logger(__name__) class PreparationScreen(Screen): @@ -27,6 +32,8 @@ class PreparationScreen(Screen): ("0,1,2,3", "app.push_screen('about')", ""), ] + scheduled_num = reactive(config_var.get()['scheduled_num']) + def __init__(self, nucleon_file: pathlib.Path, electron_file: pathlib.Path) -> None: super().__init__(name=None, id=None, classes=None) self.nucleon_file = nucleon_file @@ -38,9 +45,10 @@ class PreparationScreen(Screen): yield Header(show_clock=True) with ScrollableContainer(id="vice_container"): yield Label(f"准备就绪: [b]{self.nucleon_file.stem}[/b]\n") - yield Label(f"内容源文件对象: ./nucleon/[b]{self.nucleon_file.name}[/b]") - yield Label(f"元数据文件对象: ./electron/[b]{self.electron_file.name}[/b]") + yield Label(f"内容源文件: {config_var.get()['paths']['nucleon_dir']}/[b]{self.nucleon_file.name}[/b]") + yield Label(f"元数据文件: {config_var.get()['paths']['electron_dir']}/[b]{self.electron_file.name}[/b]") yield Label(f"\n单元数量: {len(self.nucleons_with_orbital)}\n") + yield Label(f"单次记忆数量: {self.scheduled_num}", id="schnum_label") yield Button( "开始记忆", @@ -59,6 +67,14 @@ class PreparationScreen(Screen): yield Markdown(self._get_full_content().replace("/", ""), classes="full") yield Footer() + #def watch_scheduled_num(self, old_scheduled_num, new_scheduled_num): + # logger.debug("响应", old_scheduled_num, "->", new_scheduled_num) + # try: + # one = self.query_one("#schnum_label") + # one.update(f"单次记忆数量: {new_scheduled_num}") # type: ignore + # except: + # pass + def _get_full_content(self): content = "" for nucleon, orbital in self.nucleons_with_orbital: @@ -84,6 +100,7 @@ class PreparationScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: event.stop() + logger.debug("按下按钮") if event.button.id == "start_memorizing_button": atoms = list() for nucleon, orbital in self.nucleons_with_orbital: @@ -101,9 +118,22 @@ class PreparationScreen(Screen): atom.link("electron_path", self.electron_file) atom.link("orbital_path", None) atoms.append(atom) + atoms_to_provide = list() + left_new = self.scheduled_num + for i in atoms: + i: pt.Atom + if i.registry["electron"].is_due(): + atoms_to_provide.append(i) + else: + if i.registry["electron"].is_activated(): + pass + else: + left_new -= 1 + if left_new >= 0: + atoms_to_provide.append(i) + logger.debug(f"ATP: {atoms_to_provide}") from .memorizor import MemScreen - - memscreen = MemScreen(atoms) + memscreen = MemScreen(atoms_to_provide) self.app.push_screen(memscreen) elif event.button.id == "precache_button": self.action_precache() diff --git a/src/heurams/interface/widgets/recognition.py b/src/heurams/interface/widgets/recognition.py index 67cf3cd..8817fea 100644 --- a/src/heurams/interface/widgets/recognition.py +++ b/src/heurams/interface/widgets/recognition.py @@ -105,8 +105,11 @@ class Recognition(BasePuzzleWidget): self.handler(5) def handler(self, rating): - if not self.atom.registry["electron"].is_activated() and not self.atom.registry["runtime"]["locked"]: - self.atom.registry["electron"].activate() - logger.debug(f"激活原子 {self.atom}") - self.atom.lock(1) - self.atom.minimize(5) + if not self.atom.registry["runtime"]["locked"]: + if not self.atom.registry["electron"].is_activated(): + self.atom.registry["electron"].activate() + logger.debug(f"激活原子 {self.atom}") + self.atom.lock(1) + self.atom.minimize(5) + else: + pass \ No newline at end of file diff --git a/src/heurams/kernel/particles/electron.py b/src/heurams/kernel/particles/electron.py index ecd10b2..616b21b 100644 --- a/src/heurams/kernel/particles/electron.py +++ b/src/heurams/kernel/particles/electron.py @@ -67,7 +67,7 @@ class Electron: logger.debug("Electron.is_due: 检查 ident='%s'", self.ident) result = self.algo.is_due(self.algodata) logger.debug("is_due 结果: %s", result) - return result + return (result and self.is_activated()) def is_activated(self): result = self.algodata[self.algo.algo_name]["is_activated"] diff --git a/tests/interface/test_dashboard.py b/tests/interface/test_dashboard.py new file mode 100644 index 0000000..5c84973 --- /dev/null +++ b/tests/interface/test_dashboard.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +DashboardScreen 的测试,包括单元测试和 pilot 测试。 +""" +import unittest +import tempfile +import pathlib +import time +from unittest.mock import patch, MagicMock +from textual.pilot import Pilot + +from heurams.context import ConfigContext +from heurams.services.config import ConfigFile +from heurams.interface.__main__ import HeurAMSApp +from heurams.interface.screens.dashboard import DashboardScreen + + +class TestDashboardScreenUnit(unittest.TestCase): + """DashboardScreen 的单元测试(不启动完整应用)。""" + + def setUp(self): + """在每个测试之前运行,设置临时目录和配置。""" + # 创建临时目录用于测试数据 + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_path = pathlib.Path(self.temp_dir.name) + + # 创建默认配置,并修改路径指向临时目录 + default_config_path = ( + pathlib.Path(__file__).parent.parent.parent + / "src/heurams/default/config/config.toml" + ) + self.config = ConfigFile(default_config_path) + # 更新配置中的路径 + config_data = self.config.data + config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon") + config_data["paths"]["electron_dir"] = str(self.temp_path / "electron") + config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital") + config_data["paths"]["cache_dir"] = str(self.temp_path / "cache") + # 禁用快速通过,避免测试干扰 + config_data["quick_pass"] = 0 + # 禁用时间覆盖 + config_data["daystamp_override"] = -1 + config_data["timestamp_override"] = -1 + + # 创建目录 + for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]: + pathlib.Path(config_data["paths"][dir_key]).mkdir(parents=True, exist_ok=True) + + # 使用 ConfigContext 设置配置 + self.config_ctx = ConfigContext(self.config) + self.config_ctx.__enter__() + + def tearDown(self): + """在每个测试之后清理。""" + self.config_ctx.__exit__(None, None, None) + self.temp_dir.cleanup() + + def test_compose(self): + """测试 compose 方法返回正确的部件。""" + screen = DashboardScreen() + # 手动调用 compose 并收集部件 + from textual.app import ComposeResult + result = screen.compose() + widgets = list(result) + # 检查是否包含 Header 和 Footer + from textual.widgets import Header, Footer + header_present = any(isinstance(w, Header) for w in widgets) + footer_present = any(isinstance(w, Footer) for w in widgets) + self.assertTrue(header_present) + self.assertTrue(footer_present) + # 检查是否有 ScrollableContainer + from textual.containers import ScrollableContainer + container_present = any(isinstance(w, ScrollableContainer) for w in widgets) + self.assertTrue(container_present) + # 使用 query_one 查找 union-list,即使屏幕未挂载也可能有效 + list_view = screen.query_one("#union-list") + self.assertIsNotNone(list_view) + self.assertEqual(list_view.id, "union-list") + self.assertEqual(list_view.__class__.__name__, "ListView") + + def test_item_desc_generator(self): + """测试 item_desc_generator 函数。""" + screen = DashboardScreen() + # 模拟一个文件名 + filename = "test.toml" + result = screen.item_desc_generator(filename) + self.assertIsInstance(result, dict) + self.assertIn(0, result) + self.assertIn(1, result) + # 检查内容 + self.assertIn("test.toml", result[0]) + # 由于 electron 文件不存在,应显示“尚未激活” + self.assertIn("尚未激活", result[1]) + + +@unittest.skip("Pilot 测试需要进一步配置,暂不运行") +class TestDashboardScreenPilot(unittest.TestCase): + """使用 Textual Pilot 的集成测试。""" + + def setUp(self): + """配置临时目录和配置。""" + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_path = pathlib.Path(self.temp_dir.name) + + default_config_path = ( + pathlib.Path(__file__).parent.parent.parent + / "src/heurams/default/config/config.toml" + ) + self.config = ConfigFile(default_config_path) + config_data = self.config.data + config_data["paths"]["nucleon_dir"] = str(self.temp_path / "nucleon") + config_data["paths"]["electron_dir"] = str(self.temp_path / "electron") + config_data["paths"]["orbital_dir"] = str(self.temp_path / "orbital") + config_data["paths"]["cache_dir"] = str(self.temp_path / "cache") + config_data["quick_pass"] = 0 + config_data["daystamp_override"] = -1 + config_data["timestamp_override"] = -1 + + for dir_key in ["nucleon_dir", "electron_dir", "orbital_dir", "cache_dir"]: + pathlib.Path(config_data["paths"][dir_key]).mkdir(parents=True, exist_ok=True) + + self.config_ctx = ConfigContext(self.config) + self.config_ctx.__enter__() + + def tearDown(self): + self.config_ctx.__exit__(None, None, None) + self.temp_dir.cleanup() + + def test_dashboard_loads_with_pilot(self): + """使用 Pilot 测试 DashboardScreen 加载。""" + with patch('heurams.interface.__main__.environment_check'): + app = HeurAMSApp() + # 注意:Pilot 在 Textual 6.9.0 中的用法可能不同 + # 以下为示例代码,可能需要调整 + pilot = Pilot(app) + # 等待应用启动 + pilot.pause() + screen = app.screen + self.assertEqual(screen.__class__.__name__, "DashboardScreen") + union_list = app.query_one("#union-list") + self.assertIsNotNone(union_list) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file