diff --git a/README.md b/README.md index 4fdde48..97db155 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,15 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 已完成: - **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。 -- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。 -- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1,共 10 个。网址带 `?sample=示例编号` 可直接打开指定示例。 +- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致)、ListBox,教材控件库的 Lamp、Alarm。 +- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1–3,共 12 个。网址带 `?sample=示例编号` 可直接打开指定示例。 - 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 +- 第 3 阶段(进行中):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`)。 尚未完成: -- 教材项目 4–8 的其余任务(需要摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)、账号和班级、自动评测。 +- 教材项目 4 任务 2 和项目 6–8(需要摄像头、数据库、TrackBar、PictureBox、图表)。 diff --git a/docs/设计方案.md b/docs/设计方案.md index 4da315d..7fd7958 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -166,7 +166,7 @@ IOT/ | 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` | | 后端 | 参数校验 | 计划:Zod | | C# 运行时 | 平台 | .NET 10 LTS | -| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;计划:编译时改写旧版线程 API | +| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) | | C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 | | C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | | C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | @@ -211,7 +211,7 @@ IOT/ | 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 | | 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 | | 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 任务 1、3 已完成;任务 2 需摄像头 | -| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 任务 1 已完成;任务 2 需线程 API,任务 3 需 ListBox | +| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 | | 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | | | 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | | | 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | | @@ -236,7 +236,7 @@ flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"] P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔
评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"] - P3["阶段 3 · 扩展(下学期)
教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"] + P3["阶段 3 · 扩展(进行中)
教材项目 4–8:摄像头、线程 ✔、SQLite 模拟数据库、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2 @@ -276,6 +276,10 @@ flowchart TB - 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。 - 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。 +**第 3 阶段进展**: + +- 2026-10-22:项目 5 补齐(线程、ListBox)。旧版线程 API:编译时把 `thd.Suspend()` / `Resume()` / `Abort()`、`thd.ThreadState`、`Thread.Sleep`、`new Thread(...)` 改写成平台实现(只改语义上确实是 `System.Threading.Thread` 的调用,不换行,报错和异常的行号不变;编辑器里看到的仍是学生的原始代码,也不再提示"已过时")。新版 .NET 不能从外部停住线程,平台的实现是协作式的:被挂起或终止的线程在下一个检查点停下或结束(`Thread.Sleep`、从后台线程改控件属性、调用设备类库、弹出提示框),教材的线程方法每次循环都经过这些地方;休眠中被终止的线程立即醒来结束。行为与 .NET Framework 一致的地方:线程可以挂起自己、对没启动的线程 Abort 不报错、`Resume` 没挂起的线程和直接 `Abort` 挂起的线程抛出 ThreadStateException(书上任务 2 关闭窗体时直接 `thd.Abort()`,在真实环境中也会出这个错,参考答案改为先 `Resume` 再 `Abort`,报错信息里写明这样改)。ListBox:列表项、单选、SelectedIndexChanged 先于 Click、Text 是选中项、删除选中项后取消选中;设计器工具箱加"列表框";评分规则的"包含"对列表框检查所有列表项。新增示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),各有评分规则和端到端测试,共 12 个示例。 + **其他改进**: - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 @@ -291,7 +295,7 @@ flowchart TB | 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) | | 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 | | T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 | -| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 编译时替换为平台实现的可暂停线程 | +| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 | | 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 | | 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 | | 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms | diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs index 9364640..cf346b5 100644 --- a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs @@ -79,6 +79,7 @@ public class ADAM4017Helper /// public ADAM4017ResModel SetData() { + LegacyThreads.CheckPoint(); var model = new ADAM4017ResModel(); if (string.IsNullOrWhiteSpace(_portName)) return model; model.temperatureValue = Read(0); @@ -110,6 +111,7 @@ public class ADAM4150Helper /// 读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false public ADAM4150ResModel SetData() { + LegacyThreads.CheckPoint(); var model = new ADAM4150ResModel(); if (string.IsNullOrWhiteSpace(_portName)) return model; model.bodyInfraredValue = Read(0); diff --git a/runtime/Sim.Runner/ConsoleForwarder.cs b/runtime/Sim.Runner/ConsoleForwarder.cs index 72982d2..df9d9fd 100644 --- a/runtime/Sim.Runner/ConsoleForwarder.cs +++ b/runtime/Sim.Runner/ConsoleForwarder.cs @@ -7,6 +7,9 @@ namespace Sim.Runner; /// 把学生程序的 Console 输出按行转发给浏览器(标准输出本身留给协议消息使用) internal sealed class ConsoleForwarder : TextWriter { + /// 一直不换行的输出(如教材的 while (true) Console.Write("A"))攒到这么多字符就先发出去 + private const int MaxBuffered = 4096; + private readonly StringBuilder _buffer = new(); public override Encoding Encoding => Encoding.UTF8; @@ -16,7 +19,7 @@ internal sealed class ConsoleForwarder : TextWriter lock (_buffer) { _buffer.Append(value); - if (value == '\n') FlushBuffer(); + if (value == '\n' || _buffer.Length >= MaxBuffered) FlushBuffer(); } } @@ -26,7 +29,7 @@ internal sealed class ConsoleForwarder : TextWriter lock (_buffer) { _buffer.Append(value); - if (value.Contains('\n')) FlushBuffer(); + if (value.Contains('\n') || _buffer.Length >= MaxBuffered) FlushBuffer(); } } diff --git a/runtime/Sim.Runner/LanguageServer.cs b/runtime/Sim.Runner/LanguageServer.cs index 3f5436c..69edc07 100644 --- a/runtime/Sim.Runner/LanguageServer.cs +++ b/runtime/Sim.Runner/LanguageServer.cs @@ -244,6 +244,7 @@ internal static class LanguageServer foreach (var diagnostic in compilation.GetDiagnostics() .Concat(ApiPolicy.Check(compilation)) .Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning && d.Location.IsInSource) + .Where(d => !LegacyThreadRewriter.IsSupportedObsoleteWarning(d)) .OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1)) { diagnostics.Add(StudentCompiler.ToJson(diagnostic)); diff --git a/runtime/Sim.Runner/LegacyThreadRewriter.cs b/runtime/Sim.Runner/LegacyThreadRewriter.cs new file mode 100644 index 0000000..1f8ba2b --- /dev/null +++ b/runtime/Sim.Runner/LegacyThreadRewriter.cs @@ -0,0 +1,121 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Sim.Runner; + +/// +/// 编译前改写学生代码里的旧版线程 API,让教材项目 5 的 Suspend / Resume / Abort 能在新版 .NET 上运行: +/// +/// new Thread(GetData) → new Thread(LegacyThreads.Wrap(new ThreadStart(GetData))) +/// thd.Suspend() / Resume() / Abort() → LegacyThreads.Suspend(thd) … +/// thd.ThreadState → LegacyThreads.GetThreadState(thd) +/// Thread.Sleep(2000) → LegacyThreads.Sleep(2000) +/// +/// 只改写语义上确实是 System.Threading.Thread 成员的调用;改写不换行,报错和异常堆栈里的行号不变。 +/// 学生在编辑器里看到的仍是原来的代码。 +/// +internal static class LegacyThreadRewriter +{ + private const string Target = "global::Sim.Host.LegacyThreads"; + + /// 改写整个编译;没有需要改写的地方时原样返回 + public static CSharpCompilation Rewrite(CSharpCompilation compilation) + { + var result = compilation; + foreach (var tree in compilation.SyntaxTrees) + { + // 用到线程的文件一定会出现 Thread 这个词,其他文件不用做语义分析 + if (!tree.GetText().ToString().Contains("Thread")) continue; + var rewriter = new Rewriter(compilation.GetSemanticModel(tree)); + var root = rewriter.Visit(tree.GetRoot()); + if (!rewriter.Changed) continue; + var text = SourceText.From(root.ToFullString(), Encoding.UTF8); + result = result.ReplaceSyntaxTree(tree, CSharpSyntaxTree.ParseText(text, (CSharpParseOptions)tree.Options, tree.FilePath)); + } + return result; + } + + /// 平台支持的过时线程 API 不再提示"已过时"(边写边检查时用) + public static bool IsSupportedObsoleteWarning(Diagnostic diagnostic) + { + if (diagnostic.Id is not ("CS0618" or "SYSLIB0006")) return false; + // 警告标在整个调用上,如 thd.Suspend() + var source = diagnostic.Location.SourceTree?.GetText().ToString(diagnostic.Location.SourceSpan) ?? ""; + return Regex.IsMatch(source, @"\b(Suspend|Resume|Abort)\b"); + } + + private static bool IsThread(ITypeSymbol? type) => + type is { Name: "Thread", ContainingNamespace: { Name: "Threading", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } }; + + private sealed class Rewriter(SemanticModel model) : CSharpSyntaxRewriter + { + public bool Changed { get; private set; } + + public override SyntaxNode? VisitInvocationExpression(InvocationExpressionSyntax node) + { + var method = model.GetSymbolInfo(node).Symbol as IMethodSymbol; + var visited = (InvocationExpressionSyntax)base.VisitInvocationExpression(node)!; + if (method == null || !IsThread(method.ContainingType)) return visited; + + switch (method.Name) + { + case "Suspend" or "Resume" or "Abort" when !method.IsStatic && visited.Expression is MemberAccessExpressionSyntax access: + // thd.Abort(x) → LegacyThreads.Abort(thd, x) + var arguments = visited.ArgumentList.Arguments.Insert(0, SyntaxFactory.Argument(access.Expression.WithoutTrivia())); + Changed = true; + return SyntaxFactory.InvocationExpression(Member(method.Name), SyntaxFactory.ArgumentList(arguments)) + .WithTriviaFrom(visited); + case "Sleep" when method.IsStatic: + Changed = true; + return visited.WithExpression(Member("Sleep").WithTriviaFrom(visited.Expression)); + default: + return visited; + } + } + + public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + var symbol = model.GetSymbolInfo(node).Symbol; + var visited = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node)!; + if (symbol is not IPropertySymbol { Name: "ThreadState", IsStatic: false } property || !IsThread(property.ContainingType)) return visited; + Changed = true; + return SyntaxFactory.InvocationExpression( + Member("GetThreadState"), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(visited.Expression.WithoutTrivia())))) + .WithTriviaFrom(visited); + } + + public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) => + WrapThreadStart(node, base.VisitObjectCreationExpression(node)); + + public override SyntaxNode? VisitImplicitObjectCreationExpression(ImplicitObjectCreationExpressionSyntax node) => + WrapThreadStart(node, base.VisitImplicitObjectCreationExpression(node)); + + /// new Thread(start) 的第一个参数包一层 LegacyThreads.Wrap,线程被 Abort 时安静地结束 + private SyntaxNode? WrapThreadStart(BaseObjectCreationExpressionSyntax original, SyntaxNode? visitedNode) + { + if (visitedNode is not BaseObjectCreationExpressionSyntax visited + || model.GetSymbolInfo(original).Symbol is not IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + || !IsThread(constructor.ContainingType) + || constructor.Parameters.Length == 0 + || visited.ArgumentList is not { Arguments.Count: > 0 } argumentList) + { + return visitedNode; + } + var first = argumentList.Arguments[0]; + if (first.Expression.IsKind(SyntaxKind.NullLiteralExpression)) return visited; + var delegateType = constructor.Parameters[0].Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + // new ThreadStart(方法名或 lambda):明确委托类型,Wrap 的重载就不会有歧义 + var wrapped = SyntaxFactory.ParseExpression($"{Target}.Wrap(new {delegateType}({first.Expression.WithoutTrivia().ToFullString()}))"); + Changed = true; + return visited.WithArgumentList(argumentList.WithArguments( + argumentList.Arguments.Replace(first, first.WithExpression(wrapped.WithTriviaFrom(first.Expression))))); + } + + private static ExpressionSyntax Member(string name) => SyntaxFactory.ParseExpression($"{Target}.{name}"); + } +} diff --git a/runtime/Sim.Runner/StudentCompiler.cs b/runtime/Sim.Runner/StudentCompiler.cs index 5b596f8..3fd6a87 100644 --- a/runtime/Sim.Runner/StudentCompiler.cs +++ b/runtime/Sim.Runner/StudentCompiler.cs @@ -56,7 +56,8 @@ internal static class StudentCompiler using var peStream = new MemoryStream(); using var pdbStream = new MemoryStream(); - var emit = compilation.Emit(peStream, pdbStream, + // 编译改写后的代码(旧版线程 API 换成平台实现),API 检查仍针对学生写的原始代码 + var emit = LegacyThreadRewriter.Rewrite(compilation).Emit(peStream, pdbStream, options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); var denied = ApiPolicy.Check(compilation).ToList(); diff --git a/runtime/Sim.WinForms/Forms/ComboBox.cs b/runtime/Sim.WinForms/Forms/ComboBox.cs index 495b2a5..e37c6ee 100644 --- a/runtime/Sim.WinForms/Forms/ComboBox.cs +++ b/runtime/Sim.WinForms/Forms/ComboBox.cs @@ -1,15 +1,11 @@ -using System.Collections; -using System.Collections.Generic; using System.Drawing; -using System.Linq; using System.Text.Json.Nodes; using Sim.Host; namespace System.Windows.Forms; -public class ComboBox : Control +public class ComboBox : ListControl { - private int _selectedIndex = -1; private ComboBoxStyle _dropDownStyle = ComboBoxStyle.DropDown; public ComboBox() => Items = new ObjectCollection(this); @@ -31,15 +27,15 @@ public class ComboBox : Control set => SetProp(ref _dropDownStyle, value, nameof(DropDownStyle)); } - public int SelectedIndex + public override int SelectedIndex { - get => _selectedIndex; + get => SelectedIndexValue; set { if (value < -1 || value >= Items.Count) throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。"); - if (value == _selectedIndex) return; - SetProp(ref _selectedIndex, value, nameof(SelectedIndex)); + if (value == SelectedIndexValue) return; + SetProp(ref SelectedIndexValue, value, nameof(SelectedIndex)); // 选中项变化时 Text 随之变化(先 TextChanged,再 SelectedIndexChanged,与 WinForms 一致) Text = value >= 0 ? Items[value]?.ToString() ?? "" : ""; OnSelectedIndexChanged(EventArgs.Empty); @@ -49,19 +45,17 @@ public class ComboBox : Control public object? SelectedItem { - get => _selectedIndex >= 0 ? Items[_selectedIndex] : null; + get => SelectedIndexValue >= 0 ? Items[SelectedIndexValue] : null; set => SelectedIndex = value == null ? -1 : Items.IndexOf(value); } public string SelectedText { get; set; } = ""; public event EventHandler? SelectedIndexChanged; - public event EventHandler? SelectedValueChanged; public event EventHandler? DropDown; public event EventHandler? DropDownClosed; protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e); - protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e); protected virtual void OnDropDown(EventArgs e) => DropDown?.Invoke(this, e); protected virtual void OnDropDownClosed(EventArgs e) => DropDownClosed?.Invoke(this, e); @@ -84,7 +78,7 @@ public class ComboBox : Control case "text" when _dropDownStyle != ComboBoxStyle.DropDownList: SetTextFromUser(action.Value ?? ""); var match = Items.IndexOf(Text); - if (match != _selectedIndex) _selectedIndex = match; + if (match != SelectedIndexValue) SelectedIndexValue = match; break; default: base.OnUserAction(action); @@ -92,85 +86,28 @@ public class ComboBox : Control } } - internal void ItemsChanged() + internal override void ItemsChanged() { - if (_selectedIndex >= Items.Count) _selectedIndex = -1; - NotifyChanged(nameof(Items), Items.Select(i => i?.ToString() ?? "").ToArray()); + NotifyChanged(nameof(Items), ItemTexts(Items)); + NotifyChanged(nameof(SelectedIndex), SelectedIndexValue); + } + + internal override void ItemsCleared() + { + SelectedIndex = -1; + ItemsChanged(); } protected override void WriteProps(JsonObject props) { base.WriteProps(props); - props["Items"] = PropValue.From(Items.Select(i => i?.ToString() ?? "").ToArray()); - props["SelectedIndex"] = _selectedIndex; + props["Items"] = PropValue.From(ItemTexts(Items)); + props["SelectedIndex"] = SelectedIndexValue; props["DropDownStyle"] = _dropDownStyle.ToString(); } - public class ObjectCollection : IList, IEnumerable + public class ObjectCollection : ListItemCollection { - private readonly List _items = new(); - private readonly ComboBox _owner; - - public ObjectCollection(ComboBox owner) => _owner = owner; - - public int Count => _items.Count; - public bool IsReadOnly => false; - bool IList.IsFixedSize => false; - bool ICollection.IsSynchronized => false; - object ICollection.SyncRoot => this; - - public object? this[int index] - { - get => _items[index]; - set - { - _items[index] = value; - _owner.ItemsChanged(); - } - } - - public int Add(object? item) - { - if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。"); - _items.Add(item); - _owner.ItemsChanged(); - return _items.Count - 1; - } - - public void AddRange(object[] items) - { - _items.AddRange(items); - _owner.ItemsChanged(); - } - - public void Insert(int index, object? item) - { - _items.Insert(index, item); - _owner.ItemsChanged(); - } - - public void Remove(object? item) - { - if (_items.Remove(item)) _owner.ItemsChanged(); - } - - public void RemoveAt(int index) - { - _items.RemoveAt(index); - _owner.ItemsChanged(); - } - - public void Clear() - { - _items.Clear(); - _owner.SelectedIndex = -1; - _owner.ItemsChanged(); - } - - public bool Contains(object? item) => _items.Contains(item); - public int IndexOf(object? item) => _items.IndexOf(item); - public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index); - public IEnumerator GetEnumerator() => _items.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public ObjectCollection(ComboBox owner) : base(owner) { } } } diff --git a/runtime/Sim.WinForms/Forms/Control.cs b/runtime/Sim.WinForms/Forms/Control.cs index 318ca66..851b079 100644 --- a/runtime/Sim.WinForms/Forms/Control.cs +++ b/runtime/Sim.WinForms/Forms/Control.cs @@ -104,6 +104,7 @@ public class Control : Component, IWin32Window get => _text; set { + LegacyThreads.CheckPoint(); value ??= ""; if (_text == value) return; EnsureThreadAccess(); @@ -279,6 +280,9 @@ public class Control : Component, IWin32Window OnClick(args); OnMouseClick(args); break; + case "doubleClick": + OnDoubleClick(EventArgs.Empty); + break; case "mouseDown": OnMouseDown(new MouseEventArgs(ToMouseButtons(action.Button), 1, action.X, action.Y, 0)); break; @@ -354,6 +358,8 @@ public class Control : Component, IWin32Window /// 修改字段并在需要时通知浏览器;返回值表示是否真的改变 protected bool SetProp(ref T field, T value, string prop) { + // 后台线程改界面时响应 Suspend / Abort(教材的线程方法每次循环都会改控件) + LegacyThreads.CheckPoint(); if (EqualityComparer.Default.Equals(field, value)) return false; EnsureThreadAccess(); field = value; diff --git a/runtime/Sim.WinForms/Forms/ListBox.cs b/runtime/Sim.WinForms/Forms/ListBox.cs new file mode 100644 index 0000000..f22c19c --- /dev/null +++ b/runtime/Sim.WinForms/Forms/ListBox.cs @@ -0,0 +1,144 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public enum SelectionMode { None, One, MultiSimple, MultiExtended } + +/// 列表框(教材项目 5 任务 3 显示历史灾情记录)。只支持单选。 +public class ListBox : ListControl +{ + private int _itemHeight = 12; + private BorderStyle _borderStyle = BorderStyle.Fixed3D; + + public ListBox() => Items = new ObjectCollection(this); + + protected override Size DefaultSize => new(120, 96); + + public ObjectCollection Items { get; } + + public bool FormattingEnabled { get; set; } + public bool IntegralHeight { get; set; } = true; + public bool HorizontalScrollbar { get; set; } + public bool ScrollAlwaysVisible { get; set; } + public bool MultiColumn { get; set; } + public bool Sorted { get; set; } + public SelectionMode SelectionMode { get; set; } = SelectionMode.One; + public int TopIndex { get; set; } + + public int ItemHeight + { + get => _itemHeight; + set => SetProp(ref _itemHeight, value, nameof(ItemHeight)); + } + + public BorderStyle BorderStyle + { + get => _borderStyle; + set => SetProp(ref _borderStyle, value, nameof(BorderStyle)); + } + + public override int SelectedIndex + { + get => SelectedIndexValue; + set + { + if (value < -1 || value >= Items.Count) + throw new ArgumentOutOfRangeException(nameof(value), $"“{value}”的值对于“SelectedIndex”无效。"); + if (!SetProp(ref SelectedIndexValue, value, nameof(SelectedIndex))) return; + OnSelectedIndexChanged(EventArgs.Empty); + OnSelectedValueChanged(EventArgs.Empty); + } + } + + public object? SelectedItem + { + get => SelectedIndexValue >= 0 ? Items[SelectedIndexValue] : null; + set => SelectedIndex = value == null ? -1 : Items.IndexOf(value); + } + + /// 和 WinForms 一样,列表框的 Text 是选中项的文字;赋值时选中文字相同的项 + public override string Text + { + get => GetItemText(SelectedItem); + set + { + var index = FindStringExact(value ?? ""); + if (index >= 0) SelectedIndex = index; + } + } + + public event EventHandler? SelectedIndexChanged; + + protected virtual void OnSelectedIndexChanged(EventArgs e) => SelectedIndexChanged?.Invoke(this, e); + + public void ClearSelected() => SelectedIndex = -1; + + public void SetSelected(int index, bool value) + { + if (value) SelectedIndex = index; + else if (index == SelectedIndexValue) SelectedIndex = -1; + } + + public bool GetSelected(int index) => index == SelectedIndexValue; + + /// 第一个以 s 开头的项(不区分大小写),没有时返回 -1 + public int FindString(string s) + { + for (var i = 0; i < Items.Count; i++) + if (GetItemText(Items[i]).StartsWith(s, StringComparison.OrdinalIgnoreCase)) return i; + return -1; + } + + public int FindStringExact(string s) + { + for (var i = 0; i < Items.Count; i++) + if (string.Equals(GetItemText(Items[i]), s, StringComparison.OrdinalIgnoreCase)) return i; + return -1; + } + + public void BeginUpdate() { } + public void EndUpdate() { } + + protected override void OnUserAction(UserAction action) + { + switch (action.Action) + { + case "select": + // 和 WinForms 一样,按下鼠标时先改变选中项,松开后才触发 Click + if (action.Index >= 0 && action.Index < Items.Count) SelectedIndex = action.Index; + base.OnUserAction(new UserAction { Action = "click", X = action.X, Y = action.Y }); + break; + default: + base.OnUserAction(action); + break; + } + } + + internal override void ItemsChanged() + { + NotifyChanged(nameof(Items), ItemTexts(Items)); + NotifyChanged(nameof(SelectedIndex), SelectedIndexValue); + } + + internal override void ItemsCleared() + { + SelectedIndex = -1; + ItemsChanged(); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Items"] = PropValue.From(ItemTexts(Items)); + props["SelectedIndex"] = SelectedIndexValue; + props["ItemHeight"] = _itemHeight; + props["BorderStyle"] = _borderStyle.ToString(); + } + + public class ObjectCollection : ListItemCollection + { + public ObjectCollection(ListBox owner) : base(owner) { } + } +} diff --git a/runtime/Sim.WinForms/Forms/ListControl.cs b/runtime/Sim.WinForms/Forms/ListControl.cs new file mode 100644 index 0000000..765bc36 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/ListControl.cs @@ -0,0 +1,114 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace System.Windows.Forms; + +/// ComboBox 和 ListBox 的共同基类:列表项、选中项 +public abstract class ListControl : Control +{ + /// 选中项的序号。列表项增删时直接调整(和 WinForms 一样不触发 SelectedIndexChanged) + private protected int SelectedIndexValue = -1; + + public abstract int SelectedIndex { get; set; } + + public string DisplayMember { get; set; } = ""; + public string ValueMember { get; set; } = ""; + + public event EventHandler? SelectedValueChanged; + + protected virtual void OnSelectedValueChanged(EventArgs e) => SelectedValueChanged?.Invoke(this, e); + + public string GetItemText(object? item) => item?.ToString() ?? ""; + + /// 列表项变化后调用:把新的列表和选中项发给浏览器 + internal abstract void ItemsChanged(); + + /// Items.Clear() 时调用:先取消选中,再刷新列表 + internal abstract void ItemsCleared(); + + internal void ItemInserted(int index) + { + if (SelectedIndexValue >= index) SelectedIndexValue++; + ItemsChanged(); + } + + /// 删掉的正是选中项时取消选中,删掉前面的项时选中项序号前移 + internal void ItemRemoved(int index) + { + if (SelectedIndexValue == index) SelectedIndexValue = -1; + else if (SelectedIndexValue > index) SelectedIndexValue--; + ItemsChanged(); + } + + protected string[] ItemTexts(IEnumerable items) => items.Select(GetItemText).ToArray(); +} + +/// ComboBox.ObjectCollection 和 ListBox.ObjectCollection 共用的实现 +public abstract class ListItemCollection : IList, IEnumerable +{ + private readonly List _items = new(); + private readonly ListControl _owner; + + private protected ListItemCollection(ListControl owner) => _owner = owner; + + public int Count => _items.Count; + public bool IsReadOnly => false; + bool IList.IsFixedSize => false; + bool ICollection.IsSynchronized => false; + object ICollection.SyncRoot => this; + + public object? this[int index] + { + get => _items[index]; + set + { + _items[index] = value; + _owner.ItemsChanged(); + } + } + + public int Add(object? item) + { + if (item == null) throw new ArgumentNullException(nameof(item), "值不能为 null。"); + _items.Add(item); + _owner.ItemsChanged(); + return _items.Count - 1; + } + + public void AddRange(object[] items) + { + _items.AddRange(items); + _owner.ItemsChanged(); + } + + public void Insert(int index, object? item) + { + _items.Insert(index, item); + _owner.ItemInserted(index); + } + + public void Remove(object? item) + { + var index = _items.IndexOf(item); + if (index >= 0) RemoveAt(index); + } + + public void RemoveAt(int index) + { + _items.RemoveAt(index); + _owner.ItemRemoved(index); + } + + public void Clear() + { + _items.Clear(); + _owner.ItemsCleared(); + } + + public bool Contains(object? item) => _items.Contains(item); + public int IndexOf(object? item) => _items.IndexOf(item); + public void CopyTo(Array array, int index) => ((ICollection)_items).CopyTo(array, index); + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/runtime/Sim.WinForms/Host/LegacyThreads.cs b/runtime/Sim.WinForms/Host/LegacyThreads.cs new file mode 100644 index 0000000..e94c52c --- /dev/null +++ b/runtime/Sim.WinForms/Host/LegacyThreads.cs @@ -0,0 +1,165 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Sim.Host; + +/// +/// 教材项目 5 用到的旧版线程 API:Thread.Suspend / Resume / Abort 在新版 .NET 上会抛出 PlatformNotSupportedException。 +/// 编译时把学生代码里对它们(以及 new Thread、Thread.Sleep、ThreadState)的调用改写成这里的方法(见 Sim.Runner/LegacyThreadRewriter.cs)。 +/// +/// 新版 .NET 不能从外部强行停住或中止一个线程,所以这里是"协作式"的:被挂起或中止的线程在下一个检查点停下或结束。 +/// 检查点是 Thread.Sleep、从后台线程修改控件属性、调用设备类库、弹出提示框, +/// 教材里的线程方法每次循环都会经过它们,看到的效果和 .NET Framework 一致。 +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class LegacyThreads +{ + private sealed class State + { + public volatile bool Suspended; + public volatile bool AbortRequested; + } + + // 线程少、操作不频繁,用一把锁保护所有线程的状态;挂起和休眠都在这把锁上等待 + private static readonly object Gate = new(); + private static readonly ConditionalWeakTable States = new(); + + /// 当前线程是学生用 new Thread 创建的线程时,指向它的状态 + [ThreadStatic] private static State? _current; + + private static State StateOf(Thread thread) => States.GetValue(thread, static _ => new State()); + + // ---------- 改写后的调用 ---------- + + /// new Thread(GetData) → new Thread(Wrap(new ThreadStart(GetData))):线程被中止时安静地结束 + public static ThreadStart Wrap(ThreadStart start) => () => RunThread(() => start()); + + public static ParameterizedThreadStart Wrap(ParameterizedThreadStart start) => obj => RunThread(() => start(obj)); + + private static void RunThread(Action body) + { + var state = StateOf(Thread.CurrentThread); + _current = state; + try + { + // 启动前就被 Abort 的线程(.NET Framework 里启动后立即结束) + CheckPoint(state); + body(); + } + catch (ThreadAbortedException) + { + // 和 .NET Framework 一样,被中止的线程结束时不算未处理的异常 + } + } + + public static void Suspend(Thread thread) + { + lock (Gate) + { + if ((thread.ThreadState & (ThreadState.Unstarted | ThreadState.Stopped)) != 0) + throw new ThreadStateException("线程没有在运行,无法挂起。"); + StateOf(thread).Suspended = true; + } + // 线程挂起自己:立即停下,直到别的线程调用 Resume + if (thread == Thread.CurrentThread) CheckPoint(StateOf(thread)); + } + + public static void Resume(Thread thread) + { + lock (Gate) + { + var state = StateOf(thread); + if (!state.Suspended) throw new ThreadStateException("线程没有被挂起,无法继续(Resume 只能用在调用过 Suspend 的线程上)。"); + state.Suspended = false; + Monitor.PulseAll(Gate); + } + } + + public static void Abort(Thread thread) => Abort(thread, null); + + public static void Abort(Thread thread, object? stateInfo) + { + lock (Gate) + { + if ((thread.ThreadState & ThreadState.Stopped) != 0) return; + var state = StateOf(thread); + state.AbortRequested = true; + // 正在 Thread.Sleep 的线程立即醒来结束 + Monitor.PulseAll(Gate); + // .NET Framework 不能直接中止挂起的线程:抛出异常,等它被 Resume 后才结束 + if (state.Suspended) throw new ThreadStateException("线程已挂起,不能直接终止。先调用 Resume() 让线程继续,再调用 Abort()。"); + } + if (thread == Thread.CurrentThread) throw new ThreadAbortedException(); + } + + /// thd.ThreadState:在真实状态上加上挂起、请求中止 + public static ThreadState GetThreadState(Thread thread) + { + var actual = thread.ThreadState; + if (!States.TryGetValue(thread, out var state) || (actual & ThreadState.Stopped) != 0) return actual; + var result = actual & (ThreadState.Background | ThreadState.Unstarted); + if (state.Suspended) result |= ThreadState.Suspended; + else if ((actual & ThreadState.Unstarted) == 0) result |= actual & ThreadState.WaitSleepJoin; + if (state.AbortRequested) result |= ThreadState.AbortRequested; + return result; + } + + public static void Sleep(int millisecondsTimeout) + { + var state = _current; + if (state == null) + { + Thread.Sleep(millisecondsTimeout); + return; + } + if (millisecondsTimeout < Timeout.Infinite) throw new ArgumentOutOfRangeException(nameof(millisecondsTimeout), "休眠时间不能是负数(-1 表示一直休眠)。"); + CheckPoint(state); + var deadline = Environment.TickCount64 + millisecondsTimeout; + lock (Gate) + { + // 休眠中被 Abort 时提前醒来;被 Suspend 的线程睡完后在检查点停下 + while (!state.AbortRequested) + { + if (millisecondsTimeout == Timeout.Infinite) + { + Monitor.Wait(Gate); + continue; + } + var remaining = deadline - Environment.TickCount64; + if (remaining <= 0) break; + Monitor.Wait(Gate, (int)remaining); + } + } + CheckPoint(state); + } + + public static void Sleep(TimeSpan timeout) => Sleep((int)timeout.TotalMilliseconds); + + // ---------- 检查点 ---------- + + /// 平台内部使用:学生创建的线程在这里响应 Suspend 和 Abort(其他线程直接返回) + public static void CheckPoint() + { + var state = _current; + if (state != null) CheckPoint(state); + } + + private static void CheckPoint(State state) + { + if (!state.Suspended && !state.AbortRequested) return; + lock (Gate) + { + while (state.Suspended) Monitor.Wait(Gate); + if (state.AbortRequested) throw new ThreadAbortedException(); + } + } +} + +/// 线程被 Abort 时在线程里抛出(对应 .NET Framework 的 ThreadAbortException) +public sealed class ThreadAbortedException : Exception +{ + public ThreadAbortedException() : base("正在中止线程。") { } +} diff --git a/runtime/Sim.WinForms/Host/SimHost.cs b/runtime/Sim.WinForms/Host/SimHost.cs index 805aeb7..1a184bc 100644 --- a/runtime/Sim.WinForms/Host/SimHost.cs +++ b/runtime/Sim.WinForms/Host/SimHost.cs @@ -77,13 +77,17 @@ public static class SimHost public static void SendConsole(string text) => Send(new JsonObject { ["type"] = "console", ["text"] = text }); - public static void SendDevice(string device, string action, object? data) => Send(new JsonObject + public static void SendDevice(string device, string action, object? data) { - ["type"] = "device", - ["device"] = device, - ["action"] = action, - ["data"] = PropValue.From(data), - }); + LegacyThreads.CheckPoint(); + Send(new JsonObject + { + ["type"] = "device", + ["device"] = device, + ["action"] = action, + ["data"] = PropValue.From(data), + }); + } // ---------- 异常 ---------- @@ -121,6 +125,7 @@ public static class SimHost /// public static JsonNode? Request(JsonObject message) { + LegacyThreads.CheckPoint(); var id = Interlocked.Increment(ref _nextRequestId); var pending = new PendingRequest(); Requests[id] = pending; diff --git a/samples/p5-t2-烟雾/Form1.Designer.cs b/samples/p5-t2-烟雾/Form1.Designer.cs new file mode 100644 index 0000000..8c6f022 --- /dev/null +++ b/samples/p5-t2-烟雾/Form1.Designer.cs @@ -0,0 +1,146 @@ +namespace 烟雾 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.lbl_port = new System.Windows.Forms.Label(); + this.com_port = new System.Windows.Forms.ComboBox(); + this.btn_open = new System.Windows.Forms.Button(); + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.txt_even = new System.Windows.Forms.TextBox(); + this.lbl_even = new System.Windows.Forms.Label(); + this.txt_time = new System.Windows.Forms.TextBox(); + this.lbl_time = new System.Windows.Forms.Label(); + this.groupBox1.SuspendLayout(); + this.SuspendLayout(); + // + // lbl_port + // + this.lbl_port.AutoSize = true; + this.lbl_port.Location = new System.Drawing.Point(53, 21); + this.lbl_port.Name = "lbl_port"; + this.lbl_port.Size = new System.Drawing.Size(29, 12); + this.lbl_port.TabIndex = 0; + this.lbl_port.Text = "串口"; + // + // com_port + // + this.com_port.FormattingEnabled = true; + this.com_port.Location = new System.Drawing.Point(100, 18); + this.com_port.Name = "com_port"; + this.com_port.Size = new System.Drawing.Size(121, 20); + this.com_port.TabIndex = 1; + // + // btn_open + // + this.btn_open.Location = new System.Drawing.Point(227, 16); + this.btn_open.Name = "btn_open"; + this.btn_open.Size = new System.Drawing.Size(75, 23); + this.btn_open.TabIndex = 2; + this.btn_open.Text = "开启"; + this.btn_open.UseVisualStyleBackColor = true; + this.btn_open.Click += new System.EventHandler(this.btn_open_Click); + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.txt_even); + this.groupBox1.Controls.Add(this.lbl_even); + this.groupBox1.Controls.Add(this.txt_time); + this.groupBox1.Controls.Add(this.lbl_time); + this.groupBox1.Location = new System.Drawing.Point(30, 63); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(286, 110); + this.groupBox1.TabIndex = 3; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "数据显示"; + // + // txt_even + // + this.txt_even.Location = new System.Drawing.Point(70, 70); + this.txt_even.Name = "txt_even"; + this.txt_even.Size = new System.Drawing.Size(133, 21); + this.txt_even.TabIndex = 3; + // + // lbl_even + // + this.lbl_even.AutoSize = true; + this.lbl_even.Location = new System.Drawing.Point(23, 73); + this.lbl_even.Name = "lbl_even"; + this.lbl_even.Size = new System.Drawing.Size(29, 12); + this.lbl_even.TabIndex = 2; + this.lbl_even.Text = "事件"; + // + // txt_time + // + this.txt_time.Location = new System.Drawing.Point(70, 34); + this.txt_time.Name = "txt_time"; + this.txt_time.Size = new System.Drawing.Size(133, 21); + this.txt_time.TabIndex = 1; + // + // lbl_time + // + this.lbl_time.AutoSize = true; + this.lbl_time.Location = new System.Drawing.Point(23, 37); + this.lbl_time.Name = "lbl_time"; + this.lbl_time.Size = new System.Drawing.Size(29, 12); + this.lbl_time.TabIndex = 0; + this.lbl_time.Text = "时间"; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(342, 193); + this.Controls.Add(this.groupBox1); + this.Controls.Add(this.btn_open); + this.Controls.Add(this.com_port); + this.Controls.Add(this.lbl_port); + this.Name = "Form1"; + this.Text = "烟雾"; + this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.Form1_FormClosing); + this.Load += new System.EventHandler(this.Form1_Load); + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private System.Windows.Forms.Label lbl_port; + private System.Windows.Forms.ComboBox com_port; + private System.Windows.Forms.Button btn_open; + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.TextBox txt_even; + private System.Windows.Forms.Label lbl_even; + private System.Windows.Forms.TextBox txt_time; + private System.Windows.Forms.Label lbl_time; + } +} + diff --git a/samples/p5-t2-烟雾/Form1.cs b/samples/p5-t2-烟雾/Form1.cs new file mode 100644 index 0000000..82f998f --- /dev/null +++ b/samples/p5-t2-烟雾/Form1.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; +using System.Threading;//引用线程命名空间 + +namespace 烟雾 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + bool IsOpen = false; + bool IsFirst = true;//记录线程是否第一次打开 + Thread thd;//定义Thread线程 + + private void Form1_Load(object sender, EventArgs e) + { + //用 foreach 循环把所有可用的串口号加入列表 + foreach (string item in SerialPort.GetPortNames()) + { + com_port.Items.Add(item); + } + thd = new Thread(GetData);//实例化线程,绑定线程运行方法 + Control.CheckForIllegalCrossThreadCalls = false;//允许跨线程调用 + } + + private void GetData() + { + //线程运行方法,不断循环运行 + while (true) + { + if (DeviceCtrlHelper.adam4150Helper.SetData().smokeValue)//判断烟雾状态,true为发现烟雾灾情,false为正常状态 + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "发现烟情"; + } + else + { + txt_time.Text = DateTime.Now.ToString(); + txt_even.Text = "正常"; + } + Thread.Sleep(2000);//线程休眠2s + } + } + + private void btn_open_Click(object sender, EventArgs e) + { + IsOpen = !IsOpen; + if (IsOpen) + { + DeviceCtrlHelper.adam4150Helper.Open(com_port.Text); + btn_open.Text = "关闭"; + if (IsFirst) + { + IsFirst = false; + thd.Start();//第一次打开时启动线程 + } + else + { + thd.Resume();//继续已挂起的线程 + } + } + else + { + DeviceCtrlHelper.adam4150Helper.Close(); + btn_open.Text = "开启"; + thd.Suspend();//挂起线程 + } + } + + private void Form1_FormClosing(object sender, FormClosingEventArgs e) + { + //挂起的线程不能直接终止,要先让它继续 + if (thd.ThreadState == ThreadState.Suspended) + { + thd.Resume(); + } + thd.Abort();//终止线程 + } + } +} diff --git a/samples/p5-t2-烟雾/Program.cs b/samples/p5-t2-烟雾/Program.cs new file mode 100644 index 0000000..1ea73ee --- /dev/null +++ b/samples/p5-t2-烟雾/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 烟雾 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p5-t2-烟雾/checks.yaml b/samples/p5-t2-烟雾/checks.yaml new file mode 100644 index 0000000..efdc72c --- /dev/null +++ b/samples/p5-t2-烟雾/checks.yaml @@ -0,0 +1,30 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 按钮能打开串口、开启线程,更改按钮文字 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" } + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 5s, desc: 显示当前时间 } + +- title: 能利用线程实时显示烟雾传感器数据 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - board: { smoke: true } + - expect: { control: txt_even, contains: 发现烟情, within: 5s } + - board: { smoke: false } + - expect: { control: txt_even, contains: 正常, within: 5s } + +- title: 再点按钮能关闭串口、挂起线程,再次打开时线程继续 + steps: + - select: { control: com_port, item: COM3 } + - click: btn_open + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', within: 5s, desc: 显示当前时间 } + - click: btn_open + - expect: { control: btn_open, text: 开启, desc: 按钮文字变回"开启" } + - expect: { control: txt_time, unchanged: 5s, desc: 不再更新时间(线程应已挂起) } + - click: btn_open + - expect: { control: btn_open, text: 关闭, desc: 按钮文字变成"关闭" } + - board: { smoke: true } + - expect: { control: txt_even, contains: 发现烟情, within: 5s, desc: 继续显示烟雾数据(线程应已继续) } diff --git a/samples/p5-t2-烟雾/meta.json b/samples/p5-t2-烟雾/meta.json new file mode 100644 index 0000000..b3cf63e --- /dev/null +++ b/samples/p5-t2-烟雾/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目5 任务2 采集烟雾传感器数据", + "source": "教材书 p98–101,界面取自配套参考工程,控件名按书上表格修改;关闭窗体时先让挂起的线程继续再终止(书上直接 Abort 挂起的线程会出错)" +} diff --git a/samples/p5-t3-推送灾情/Form1.Designer.cs b/samples/p5-t3-推送灾情/Form1.Designer.cs new file mode 100644 index 0000000..c39b172 --- /dev/null +++ b/samples/p5-t3-推送灾情/Form1.Designer.cs @@ -0,0 +1,241 @@ +namespace 火焰烟雾 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.led1 = new WinFormsDeviceControlLibrary.LED(); + this.lbl_time = new System.Windows.Forms.Label(); + this.txt_time = new System.Windows.Forms.TextBox(); + this.txt_fire = new System.Windows.Forms.TextBox(); + this.lbl_fire = new System.Windows.Forms.Label(); + this.txt_smoke = new System.Windows.Forms.TextBox(); + this.lbl_smoke = new System.Windows.Forms.Label(); + this.lbl_Ledport = new System.Windows.Forms.Label(); + this.com_ledPort = new System.Windows.Forms.ComboBox(); + this.com_environmentPort = new System.Windows.Forms.ComboBox(); + this.lbl_4150port = new System.Windows.Forms.Label(); + this.btn_Open = new System.Windows.Forms.Button(); + this.btn_get = new System.Windows.Forms.Button(); + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.OpenRecord = new System.Windows.Forms.Button(); + this.groupBox2 = new System.Windows.Forms.GroupBox(); + this.groupBox1.SuspendLayout(); + this.groupBox2.SuspendLayout(); + this.SuspendLayout(); + // + // led1 + // + this.led1.BackColor = System.Drawing.Color.Black; + this.led1.Location = new System.Drawing.Point(19, 0); + this.led1.Name = "led1"; + this.led1.Size = new System.Drawing.Size(800, 130); + this.led1.TabIndex = 0; + // + // lbl_time + // + this.lbl_time.AutoSize = true; + this.lbl_time.Location = new System.Drawing.Point(62, 163); + this.lbl_time.Name = "lbl_time"; + this.lbl_time.Size = new System.Drawing.Size(29, 12); + this.lbl_time.TabIndex = 1; + this.lbl_time.Text = "时间"; + // + // txt_time + // + this.txt_time.Location = new System.Drawing.Point(109, 160); + this.txt_time.Name = "txt_time"; + this.txt_time.Size = new System.Drawing.Size(174, 21); + this.txt_time.TabIndex = 2; + // + // txt_fire + // + this.txt_fire.Location = new System.Drawing.Point(109, 205); + this.txt_fire.Name = "txt_fire"; + this.txt_fire.Size = new System.Drawing.Size(174, 21); + this.txt_fire.TabIndex = 4; + // + // lbl_fire + // + this.lbl_fire.AutoSize = true; + this.lbl_fire.Location = new System.Drawing.Point(62, 208); + this.lbl_fire.Name = "lbl_fire"; + this.lbl_fire.Size = new System.Drawing.Size(29, 12); + this.lbl_fire.TabIndex = 3; + this.lbl_fire.Text = "火焰"; + // + // txt_smoke + // + this.txt_smoke.Location = new System.Drawing.Point(109, 256); + this.txt_smoke.Name = "txt_smoke"; + this.txt_smoke.Size = new System.Drawing.Size(174, 21); + this.txt_smoke.TabIndex = 6; + // + // lbl_smoke + // + this.lbl_smoke.AutoSize = true; + this.lbl_smoke.Location = new System.Drawing.Point(62, 259); + this.lbl_smoke.Name = "lbl_smoke"; + this.lbl_smoke.Size = new System.Drawing.Size(29, 12); + this.lbl_smoke.TabIndex = 5; + this.lbl_smoke.Text = "烟雾"; + // + // lbl_Ledport + // + this.lbl_Ledport.AutoSize = true; + this.lbl_Ledport.Location = new System.Drawing.Point(29, 26); + this.lbl_Ledport.Name = "lbl_Ledport"; + this.lbl_Ledport.Size = new System.Drawing.Size(47, 12); + this.lbl_Ledport.TabIndex = 7; + this.lbl_Ledport.Text = "L E D"; + // + // com_ledPort + // + this.com_ledPort.FormattingEnabled = true; + this.com_ledPort.Location = new System.Drawing.Point(88, 23); + this.com_ledPort.Name = "com_ledPort"; + this.com_ledPort.Size = new System.Drawing.Size(121, 20); + this.com_ledPort.TabIndex = 8; + // + // com_environmentPort + // + this.com_environmentPort.FormattingEnabled = true; + this.com_environmentPort.Location = new System.Drawing.Point(88, 49); + this.com_environmentPort.Name = "com_environmentPort"; + this.com_environmentPort.Size = new System.Drawing.Size(121, 20); + this.com_environmentPort.TabIndex = 10; + // + // lbl_4150port + // + this.lbl_4150port.AutoSize = true; + this.lbl_4150port.Location = new System.Drawing.Point(29, 52); + this.lbl_4150port.Name = "lbl_4150port"; + this.lbl_4150port.Size = new System.Drawing.Size(53, 12); + this.lbl_4150port.TabIndex = 9; + this.lbl_4150port.Text = "环境采集"; + // + // btn_Open + // + this.btn_Open.Location = new System.Drawing.Point(134, 85); + this.btn_Open.Name = "btn_Open"; + this.btn_Open.Size = new System.Drawing.Size(75, 23); + this.btn_Open.TabIndex = 11; + this.btn_Open.Text = "开启"; + this.btn_Open.UseVisualStyleBackColor = true; + this.btn_Open.Click += new System.EventHandler(this.btn_Open_Click); + // + // btn_get + // + this.btn_get.Location = new System.Drawing.Point(270, 20); + this.btn_get.Name = "btn_get"; + this.btn_get.Size = new System.Drawing.Size(49, 117); + this.btn_get.TabIndex = 12; + this.btn_get.Text = "获取"; + this.btn_get.UseVisualStyleBackColor = true; + this.btn_get.Click += new System.EventHandler(this.btn_get_Click); + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.OpenRecord); + this.groupBox1.Controls.Add(this.lbl_Ledport); + this.groupBox1.Controls.Add(this.btn_Open); + this.groupBox1.Controls.Add(this.com_ledPort); + this.groupBox1.Controls.Add(this.com_environmentPort); + this.groupBox1.Controls.Add(this.lbl_4150port); + this.groupBox1.Location = new System.Drawing.Point(447, 143); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(375, 150); + this.groupBox1.TabIndex = 13; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "配置"; + // + // OpenRecord + // + this.OpenRecord.Location = new System.Drawing.Point(256, 23); + this.OpenRecord.Name = "OpenRecord"; + this.OpenRecord.Size = new System.Drawing.Size(99, 25); + this.OpenRecord.TabIndex = 13; + this.OpenRecord.Text = "历史灾情信息"; + this.OpenRecord.UseVisualStyleBackColor = true; + this.OpenRecord.Click += new System.EventHandler(this.OpenRecord_Click); + // + // groupBox2 + // + this.groupBox2.Controls.Add(this.btn_get); + this.groupBox2.Location = new System.Drawing.Point(19, 136); + this.groupBox2.Name = "groupBox2"; + this.groupBox2.Size = new System.Drawing.Size(402, 157); + this.groupBox2.TabIndex = 14; + this.groupBox2.TabStop = false; + this.groupBox2.Text = "数据采集"; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(834, 315); + this.Controls.Add(this.txt_smoke); + this.Controls.Add(this.lbl_smoke); + this.Controls.Add(this.txt_fire); + this.Controls.Add(this.lbl_fire); + this.Controls.Add(this.txt_time); + this.Controls.Add(this.lbl_time); + this.Controls.Add(this.led1); + this.Controls.Add(this.groupBox1); + this.Controls.Add(this.groupBox2); + this.Name = "Form1"; + this.Text = "火焰烟雾"; + this.Load += new System.EventHandler(this.Form1_Load); + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.groupBox2.ResumeLayout(false); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private WinFormsDeviceControlLibrary.LED led1; + private System.Windows.Forms.Label lbl_time; + private System.Windows.Forms.TextBox txt_time; + private System.Windows.Forms.TextBox txt_fire; + private System.Windows.Forms.Label lbl_fire; + private System.Windows.Forms.TextBox txt_smoke; + private System.Windows.Forms.Label lbl_smoke; + private System.Windows.Forms.Label lbl_Ledport; + private System.Windows.Forms.ComboBox com_ledPort; + private System.Windows.Forms.ComboBox com_environmentPort; + private System.Windows.Forms.Label lbl_4150port; + private System.Windows.Forms.Button btn_Open; + private System.Windows.Forms.Button btn_get; + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.GroupBox groupBox2; + private System.Windows.Forms.Button OpenRecord; + } +} + diff --git a/samples/p5-t3-推送灾情/Form1.cs b/samples/p5-t3-推送灾情/Form1.cs new file mode 100644 index 0000000..90fe167 --- /dev/null +++ b/samples/p5-t3-推送灾情/Form1.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; +using DeviceCtrlLibrary.Model;//引用设备控制库下的Model命名空间 + +namespace 火焰烟雾 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + ADAM4150ResModel ResModel; + bool IsOpen = false; + string LED_MSG = "";//存储LED显示屏所需发送的文字信息 + + private void Form1_Load(object sender, EventArgs e) + { + com_environmentPort.Items.Clear(); + com_ledPort.Items.Clear(); + //将系统串口名依次加入下拉列表框中,由于本题使用串口服务器,所以COM1计算机串口可不加入串口列表 + for (int i = 0; i < SerialPort.GetPortNames().Length; i++) + { + //判断计算机的默认串口,将其排除 + if (SerialPort.GetPortNames()[i].ToUpper() == "COM1") + { + continue;//跳出此次循环 + } + com_environmentPort.Items.Add(SerialPort.GetPortNames()[i].ToString()); + com_ledPort.Items.Add(SerialPort.GetPortNames()[i].ToString()); + } + } + + private void btn_Open_Click(object sender, EventArgs e) + { + IsOpen = !IsOpen; + if (IsOpen) + { + DeviceCtrlHelper.adam4150Helper.Open(com_environmentPort.Text); + DeviceCtrlHelper.ledHelper.OpenLedCom(com_ledPort.Text); + btn_Open.Text = "关闭"; + } + else + { + DeviceCtrlHelper.adam4150Helper.Close(); + btn_Open.Text = "开启"; + } + } + + private void btn_get_Click(object sender, EventArgs e) + { + ResModel = DeviceCtrlHelper.adam4150Helper.SetData();//获取数字量采集器ADAM 4150的最新数据 + LED_MSG = ""; + txt_time.Text = DateTime.Now.ToString(); + txt_fire.Text = "正常"; + txt_smoke.Text = "正常"; + if (ResModel.fireValue) + { + txt_fire.Text = "发现火情"; + LED_MSG += "发现火情"; + RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text;//记录灾情时间至数组 + RecordClass.record[RecordClass.recoudeindex, 1] = " 发现火情";//记录灾情内容至数组 + RecordClass.recoudeindex++; + } + if (ResModel.smokeValue) + { + txt_smoke.Text = "发现烟情"; + LED_MSG += "发现烟情"; + RecordClass.record[RecordClass.recoudeindex, 0] = txt_time.Text;//记录灾情时间至数组 + RecordClass.record[RecordClass.recoudeindex, 1] = " 发现烟情";//记录灾情内容至数组 + RecordClass.recoudeindex++; + } + string ResStr = DeviceCtrlHelper.ledHelper.SendLedString(LED_MSG); + if (!(ResStr.IndexOf("Error") >= 0)) + led1.setLedText(ResStr);//推送灾情信息至LED显示屏 + } + + private void OpenRecord_Click(object sender, EventArgs e) + { + Form2 fr2 = new Form2(); + fr2.Show(); + } + } +} diff --git a/samples/p5-t3-推送灾情/Form2.Designer.cs b/samples/p5-t3-推送灾情/Form2.Designer.cs new file mode 100644 index 0000000..cedd669 --- /dev/null +++ b/samples/p5-t3-推送灾情/Form2.Designer.cs @@ -0,0 +1,60 @@ +namespace 火焰烟雾 +{ + partial class Form2 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.lstbRecored = new System.Windows.Forms.ListBox(); + this.SuspendLayout(); + // + // lstbRecored + // + this.lstbRecored.FormattingEnabled = true; + this.lstbRecored.ItemHeight = 12; + this.lstbRecored.Location = new System.Drawing.Point(12, 26); + this.lstbRecored.Name = "lstbRecored"; + this.lstbRecored.Size = new System.Drawing.Size(260, 148); + this.lstbRecored.TabIndex = 0; + // + // Form2 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(284, 185); + this.Controls.Add(this.lstbRecored); + this.Name = "Form2"; + this.Text = "历史灾情记录"; + this.Load += new System.EventHandler(this.Form2_Load); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.ListBox lstbRecored; + } +} \ No newline at end of file diff --git a/samples/p5-t3-推送灾情/Form2.cs b/samples/p5-t3-推送灾情/Form2.cs new file mode 100644 index 0000000..a338751 --- /dev/null +++ b/samples/p5-t3-推送灾情/Form2.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 火焰烟雾 +{ + public partial class Form2 : Form + { + public Form2() + { + InitializeComponent(); + } + + private void Form2_Load(object sender, EventArgs e) + { + //把数组里记录的灾情信息逐条加入列表框 + for (int i = 0; i < RecordClass.recoudeindex; i++) + { + lstbRecored.Items.Add(RecordClass.record[i, 0] + " " + RecordClass.record[i, 1]); + } + } + } +} diff --git a/samples/p5-t3-推送灾情/Program.cs b/samples/p5-t3-推送灾情/Program.cs new file mode 100644 index 0000000..a66efee --- /dev/null +++ b/samples/p5-t3-推送灾情/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 火焰烟雾 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p5-t3-推送灾情/RecordClass.cs b/samples/p5-t3-推送灾情/RecordClass.cs new file mode 100644 index 0000000..b95c1d9 --- /dev/null +++ b/samples/p5-t3-推送灾情/RecordClass.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace 火焰烟雾 +{ + class RecordClass + { + public static string[,] record = new string[50, 2];//定义一个二维数组,用于存储灾情信息的时间和内容 + public static int recoudeindex = 0;//用于记录灾情信息的索引位置 + } +} diff --git a/samples/p5-t3-推送灾情/checks.yaml b/samples/p5-t3-推送灾情/checks.yaml new file mode 100644 index 0000000..ebd5762 --- /dev/null +++ b/samples/p5-t3-推送灾情/checks.yaml @@ -0,0 +1,40 @@ +# 评分规则:书上评价表的功能项。每项单独启动一次程序,按步骤操作,核对结果。 +# 格式说明见 server/src/grading/checks.ts +- title: 能实时显示火焰、烟雾传感器数据 + steps: + - select: { control: com_environmentPort, item: COM3 } + - select: { control: com_ledPort, item: COM5 } + - click: btn_Open + - expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" } + - board: { fire: true } + - click: btn_get + - expect: { control: txt_fire, contains: 发现火情 } + - expect: { control: txt_smoke, contains: 正常 } + - expect: { control: txt_time, matches: '\d{1,2}:\d{2}', desc: 显示当前时间 } + - board: { fire: false, smoke: true } + - click: btn_get + - expect: { control: txt_fire, contains: 正常 } + - expect: { control: txt_smoke, contains: 发现烟情 } + +- title: 能正确推送灾情信息到 LED 屏 + steps: + - select: { control: com_environmentPort, item: COM3 } + - select: { control: com_ledPort, item: COM5 } + - click: btn_Open + - board: { fire: true } + - click: btn_get + - expect: { led: 发现火情 } + - expect: { control: led1, ledText: 发现火情, desc: 窗体上的 LED 控件显示"发现火情" } + +- title: 能利用数组存储灾情信息,在历史灾情窗体里显示 + steps: + - select: { control: com_environmentPort, item: COM3 } + - select: { control: com_ledPort, item: COM5 } + - click: btn_Open + - board: { fire: true } + - click: btn_get + - board: { fire: false, smoke: true } + - click: btn_get + - click: OpenRecord + - expect: { control: lstbRecored, contains: 发现火情, desc: 列表里有"发现火情"的记录 } + - expect: { control: lstbRecored, contains: 发现烟情, desc: 列表里有"发现烟情"的记录 } diff --git a/samples/p5-t3-推送灾情/meta.json b/samples/p5-t3-推送灾情/meta.json new file mode 100644 index 0000000..2619c9f --- /dev/null +++ b/samples/p5-t3-推送灾情/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目5 任务3 实时推送灾情信息", + "source": "教材书 p104–107,界面取自配套参考工程,控件名按书上表格修改;修正烟雾记成“发现火情”的错误,列表框按表5-7 命名为 lstbRecored" +} diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index 8e26fd7..4f57c6a 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -19,6 +19,7 @@ export type Step = export type Expectation = /** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,on → IsOn(灯泡、报警灯) */ | { kind: 'prop'; control: string; prop: string; value: unknown } + /** 控件文字包含某段文字;列表框是任意一个列表项包含 */ | { kind: 'contains'; control: string; text: string } | { kind: 'matches'; control: string; pattern: RegExp; source: string } /** 一段时间内控件文字不变(比如定时器已经停止) */ diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 42d35b9..0e43774 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -134,13 +134,20 @@ async function expectStep(driver: ProgramDriver, step: Extract): boolean { const props = (name: string) => driver.find(name)?.props switch (e.kind) { case 'prop': return props(e.control)?.[e.prop] === e.value - case 'contains': - return String(props(e.control)?.Text ?? '').includes(e.text) + case 'contains': { + const items = listItems(driver.find(e.control)) + return items ? items.some((item) => item.includes(e.text)) : String(props(e.control)?.Text ?? '').includes(e.text) + } case 'matches': return e.pattern.test(String(props(e.control)?.Text ?? '')) case 'led': @@ -168,8 +175,12 @@ function describeFailure(driver: ProgramDriver, e: Exclude `"${i}"`).join('、')}` : '列表是空的'}` + return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}` + } case 'matches': return `期望${prefix} ${e.control} ${desc ?? `的文字符合 ${e.pattern}`},实际是${quote(need(driver, e.control).props.Text)}` case 'led': diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 285ebaf..4ec7cdb 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -425,6 +425,13 @@ describe('语言服务', () => { expect((await t.post('/api/language', { type: 'run', session: 'tab1' })).status).toBe(400) expect((await new Browser().post('/api/language', { type: 'complete', session: 'tab1', files })).status).toBe(401) }, 60000) + + test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => { + const t = await teacher() + const files = [{ path: 'Program.cs', content: 'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n' }] + const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab2', files }) + expect(check.body.diagnostics).toEqual([]) + }, 60000) }) describe('AI 评阅', () => { diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index acd8212..5d4c92d 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -31,7 +31,7 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st describe('参考答案全部通过', async () => { const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() - test('每个示例都有评分规则', () => expect(ids.length).toBe(10)) + test('每个示例都有评分规则', () => expect(ids.length).toBe(12)) for (const id of ids) { test(id, async () => { const result = await grade(await prepare(id, id), await checksOf(id)) @@ -80,6 +80,22 @@ describe('改坏的代码', () => { expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从') }, 60000) + test('关闭时忘了挂起线程', async () => { + const id = 'p5-t2-烟雾' + const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' }) + const result = await grade(dir, await checksOf(id)) + const stop = result.results.find((r) => r.title === '再点按钮能关闭串口、挂起线程,再次打开时线程继续')! + expect(stop.passed).toBe(false) + expect(stop.message).toStartWith('期望 txt_time 不再更新时间(线程应已挂起),实际从') + }, 60000) + + test('历史灾情窗体没有把数组里的记录加入列表框', async () => { + const id = 'p5-t3-推送灾情' + const dir = await prepare(id, 'listbox', { file: 'Form2.cs', from: 'lstbRecored.Items.Add(', to: 'Console.WriteLine(' }) + const result = await grade(dir, await checksOf(id)) + expect(result.results[2]).toMatchObject({ passed: false, message: '期望 lstbRecored 列表里有"发现火情"的记录,实际列表是空的' }) + }, 60000) + test('报警灯接错了 DO 口', async () => { const id = 'p4-t3-报警灯' const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' }) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts index bb728de..a1ee074 100644 --- a/server/test/runner.test.ts +++ b/server/test/runner.test.ts @@ -335,6 +335,52 @@ test('项目5 任务1:有火焰时显示"发现火情!"', async () => { await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000) }, 30000) +test('项目5 任务2:线程实时显示烟雾数据;关闭时线程挂起、再打开时继续;关闭窗体后程序正常结束', async () => { + const { runner, id, form } = await startSample('p5-t2-烟雾') + // 改写后的代码不再用过时的 API,不应出现"已过时"警告 + expect((await runner.waitFor('compiled')).diagnostics).toEqual([]) + const changed = (name: string, match: (value: unknown) => boolean = () => true, timeout = 5000) => + runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && match(c.value)), timeout) + const normal = changed('txt_even', (v) => v === '正常') + await openAdam4150(runner, id) + await normal + runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, true, false] }) + await changed('txt_even', (v) => v === '发现烟情') + + // 关闭:线程挂起,时间不再更新 + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + await changed('btn_open', (v) => v === '开启') + await expect(changed('txt_time', undefined, 3000)).rejects.toThrow('超时') + + // 再打开:线程继续 + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + await changed('txt_time', undefined, 3000) + + // 挂起后关闭窗体:先 Resume 再 Abort,程序正常结束 + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + await changed('btn_open', (v) => v === '开启') + runner.send({ type: 'action', id: form.id, action: 'close' }) + expect((await runner.finished()).code).toBe(0) + expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([]) +}, 30000) + +test('项目5 任务2:照书上直接 Abort 挂起的线程,和 .NET Framework 一样报错,并提示先 Resume', async () => { + const dir = await prepareSample('p5-t2-烟雾') + const file = `${dir}/Form1.cs` + const code = await Bun.file(file).text() + await Bun.write(file, code.replace(/if \(thd\.ThreadState == ThreadState\.Suspended\)\s*\{\s*thd\.Resume\(\);\s*\}/, '')) + runner = await Runner.start(dir) + const { form } = await runner.waitFor('formShown') + const id = (name: string) => findByName(form, name).id + await openAdam4150(runner, id) + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === id('btn_open') && c.value === '开启')) + runner.send({ type: 'action', id: form.id, action: 'close' }) + const error = await runner.next('exception') + expect(error.message).toContain('ThreadStateException') + expect(error.message).toContain('先调用 Resume()') +}, 30000) + /** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */ async function runMain(name: string, body: string, usings = '') { const dir = resolve(ROOT, '.runs', `test-${name}`) @@ -347,6 +393,96 @@ async function runMain(name: string, body: string, usings = '') { return { runner, dir } } +test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠中被终止、ThreadState', async () => { + const { runner } = await runMain( + 'legacy-threads', + ` + int count = 0; + Thread worker = new Thread(() => { while (true) { count++; Thread.Sleep(20); } }); + Console.WriteLine("state0=" + worker.ThreadState); + worker.Start(); + Thread.Sleep(200); + worker.Suspend(); + Thread.Sleep(100); + int frozen = count; + Console.WriteLine("state1=" + worker.ThreadState); + Thread.Sleep(200); + Console.WriteLine("frozen=" + (count == frozen)); + try { worker.Abort(); } catch (ThreadStateException) { Console.WriteLine("abortSuspended=error"); } + worker.Resume(); + Console.WriteLine("joined=" + worker.Join(1000) + "," + worker.ThreadState); + + Thread self = null; + self = new Thread(() => { self.Suspend(); Console.WriteLine("self=resumed"); }); + self.Start(); + Thread.Sleep(100); + Console.WriteLine("selfState=" + self.ThreadState); + self.Resume(); + self.Join(1000); + + Thread sleeper = new Thread(new ThreadStart(Sleepy)); + sleeper.Start(); + Thread.Sleep(100); + try { sleeper.Resume(); } catch (ThreadStateException) { Console.WriteLine("resumeRunning=error"); } + sleeper.Abort(); + Console.WriteLine("sleeperJoined=" + sleeper.Join(1000)); + + Thread unstarted = new Thread(Sleepy); + unstarted.Abort(); + Console.WriteLine("unstarted=" + unstarted.ThreadState); + + var param = new Thread(o => Console.WriteLine("param=" + o)); + param.Start(42); + param.Join(); + } + + static void Sleepy() + { + Thread.Sleep(100000); + Console.WriteLine("sleeper=woke");`, + 'using System.Threading;', + ) + expect((await runner.waitFor('compiled')).ok).toBe(true) + await runner.finished() + const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('') + expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([]) + for (const line of [ + 'state0=Unstarted', 'state1=Suspended', 'frozen=True', 'abortSuspended=error', 'joined=True,Stopped', + 'selfState=Suspended', 'self=resumed', 'resumeRunning=error', 'sleeperJoined=True', 'unstarted=Unstarted, AbortRequested', 'param=42', + ]) { + expect(output).toContain(line) + } + expect(output).not.toContain('sleeper=woke') +}, 30000) + +test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click,Text 是选中项;双击触发 DoubleClick', async () => { + const { runner } = await runMain( + 'listbox', + ` + var form = new Form(); + var list = new ListBox { Name = "list" }; + list.Items.AddRange(new object[] { "甲", "乙", "丙" }); + list.SelectedIndexChanged += (s, e) => Console.WriteLine("changed=" + list.SelectedIndex + "," + list.Text); + list.Click += (s, e) => Console.WriteLine("click"); + list.DoubleClick += (s, e) => { Console.WriteLine("double=" + list.SelectedItem); list.Items.RemoveAt(1); }; + form.Controls.Add(list); + Application.Run(form);`, + 'using System.Windows.Forms;', + ) + const { form } = await runner.waitFor('formShown') + const list = findByName(form, 'list') + expect(list.props.Items).toEqual(['甲', '乙', '丙']) + runner.send({ type: 'action', id: list.id, action: 'select', index: 1 }) + await runner.waitFor('console', (m) => m.text === 'click\n') + runner.send({ type: 'action', id: list.id, action: 'doubleClick' }) + // 删掉选中的项后取消选中,浏览器收到新的列表 + const patch = await runner.next('patch', (m) => m.changes.some((c) => c.prop === 'Items')) + expect(patch.changes).toContainEqual({ id: list.id, prop: 'SelectedIndex', value: -1 }) + expect(patch.changes).toContainEqual({ id: list.id, prop: 'Items', value: ['甲', '丙'] }) + const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('') + expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n') +}, 30000) + test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => { process.env.SIM_TEST_SECRET = '不该被看到' const { runner } = await runMain( diff --git a/shared/protocol.ts b/shared/protocol.ts index c439759..36c5775 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -107,6 +107,7 @@ export type UserAction = | { action: 'text'; value: string } | { action: 'dropDown' } | { action: 'select'; index: number } + | { action: 'doubleClick' } | { action: 'mouseDown'; button: 'left' | 'right'; x: number; y: number } | { action: 'mouseUp'; button: 'left' | 'right'; x: number; y: number } | { action: 'close' } diff --git a/web/src/designer/catalog.ts b/web/src/designer/catalog.ts index 1bcdd90..51084bf 100644 --- a/web/src/designer/catalog.ts +++ b/web/src/designer/catalog.ts @@ -159,6 +159,26 @@ export const CONTROLS: ControlDef[] = [ events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }], initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]], }, + { + type: `${FORMS}.ListBox`, + label: '列表框', + group: '常用控件', + namePrefix: 'listBox', + defaultEvent: 'SelectedIndexChanged', + props: [ + ...COMMON_PROPS.filter((p) => p.name !== 'Text'), + { name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' }, + { + name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`, + options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观', + }, + ], + events: [ + ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), + { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, + ], + initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]], + }, { type: `${FORMS}.CheckBox`, label: '复选框', diff --git a/web/src/designer/preview.ts b/web/src/designer/preview.ts index 05650de..36e1c33 100644 --- a/web/src/designer/preview.ts +++ b/web/src/designer/preview.ts @@ -63,7 +63,7 @@ export function previewTree(doc: DesignerDocument): ControlNode { const props: Record = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) } // 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑 props.Visible = true - if (type === 'System.Windows.Forms.ComboBox') props.Items = getItems(doc, name).map((item) => { + if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox') props.Items = getItems(doc, name).map((item) => { const v = parseValue(item) return v.kind === 'string' ? v.value : item }) diff --git a/web/src/winforms/ControlView.vue b/web/src/winforms/ControlView.vue index 7088c48..a2d47f8 100644 --- a/web/src/winforms/ControlView.vue +++ b/web/src/winforms/ControlView.vue @@ -48,6 +48,9 @@ function selectItem(index: number) { dropDownOpen.value = false act({ action: 'select', index }) } + +// ListBox +const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined) ?? 12}px`)