项目 5 补齐:旧版线程 API(Suspend / Resume / Abort)与 ListBox

- 编译时把 Thread 的 Suspend / Resume / Abort、ThreadState、Sleep、new Thread 改写成平台的
  协作式实现:被挂起或终止的线程在 Sleep、后台改控件、调设备类库、弹提示框时停下或结束;
  ThreadStateException 等行为与 .NET Framework 一致;编辑器不再提示"已过时"
- ListBox:列表项、单选、SelectedIndexChanged 先于 Click、删除选中项后取消选中;
  ComboBox 与 ListBox 共用 ListControl 和列表项集合;设计器工具箱加"列表框"
- 评分规则的"包含"对列表框检查所有列表项
- 示例:项目 5 任务 2(烟雾,线程)、任务 3(推送灾情,二维数组、两个窗体、ListBox),
  参考答案修正书上直接 Abort 挂起线程、烟雾记成"发现火情"的问题
- 一直不换行的 Console 输出攒满 4096 字符先发出,避免占满内存
- 测试:旧版线程 API、ListBox 事件顺序、两个示例的端到端和评分测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 20:33:12 +08:00
1 parent 8a92354fe4
commit 91b39603a2
36 files changed
+1653 -105

No files matched your search

+7
View File
@@ -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 评阅', () => {
+17 -1
View File
@@ -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);' })
+136
View File
@@ -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(