项目 4 任务 2 网络摄像机:PictureBox、Image / Bitmap、照片列表
- 摄像头:自绘东围墙场景(Camera/scene.svg),按云台位置切成 640×480 帧(build-frames.ts); Pan 每次转一格,红外对射被遮挡时画面出现翻墙的人;类名、委托、事件参数与教材 dll 一致, ImageRead 每 250 ms 在后台线程触发 - Image / Bitmap 保存真实图片数据:Save、FromFile、FromStream,文件夹不存在时报 GDI+ 一般性错误; 图片每张只发给浏览器一次(按内容哈希缓存) - 程序所在目录(AppDomain.CurrentDomain.BaseDirectory,编译检查单独放行)和当前目录指向可写的 bin/Debug, 沙箱里在沙箱的临时目录 - PictureBox(SizeMode、BorderStyle);设计器工具箱加"图片框";运行区显示程序保存的图片,点开看大图; 实训台摄像头图标显示开启状态 - 评分规则加图片框显示图片、摄像头开关与转向、保存图片三种检查;示例 p4-t2-网络摄像机 - 测试:摄像头端到端、图片读写与程序目录、AppDomain 编译检查、评分出错情况 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ed9450c02
commit
6b088240da
76 files changed
+1290
-14
No files matched your search
@@ -15,6 +15,7 @@ export function defaultBoardState(): BoardState {
|
||||
relays: [false, false, false, false, false],
|
||||
zigbee: [false, false],
|
||||
ledText: '',
|
||||
camera: { on: false, x: 3, y: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +70,25 @@ export class Board {
|
||||
this.state.zigbee[relay] = on === 1
|
||||
return true
|
||||
}
|
||||
case 'camera/on':
|
||||
this.state.camera.on = message.data === true
|
||||
return true
|
||||
case 'camera/pan': {
|
||||
const [x, y] = message.data as [number, number]
|
||||
this.state.camera.x = x
|
||||
this.state.camera.y = y
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始) */
|
||||
programExited() {
|
||||
this.state.camera = { on: false, x: 3, y: 1 }
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = defaultBoardState()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ export type Expectation =
|
||||
| { kind: 'relay'; index: number; on: boolean }
|
||||
| { kind: 'zigbee'; index: number; on: boolean }
|
||||
| { kind: 'messageBox'; text: string }
|
||||
/** 图片框里显示了图片 */
|
||||
| { kind: 'image'; control: string }
|
||||
/** 摄像头在传画面(on)/ 已关闭(off),或云台比开始时朝某个方向转过 */
|
||||
| { kind: 'camera'; state: 'on' | 'off' | 'up' | 'down' | 'left' | 'right' }
|
||||
/** 程序用 Image.Save 保存了图片,路径包含这段文字(如 Camera/) */
|
||||
| { kind: 'savedImage'; path: string }
|
||||
| { kind: 'console'; text: string }
|
||||
|
||||
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
|
||||
@@ -59,6 +65,12 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
|
||||
if ('led' in raw) return { kind: 'led', text: str(raw.led, where, ' LED 屏应显示的文字') }
|
||||
if ('messageBox' in raw) return { kind: 'messageBox', text: str(raw.messageBox, where, '提示框应包含的文字') }
|
||||
if ('console' in raw) return { kind: 'console', text: str(raw.console, where, '控制台应输出的文字') }
|
||||
if ('camera' in raw) {
|
||||
const state = String(raw.camera)
|
||||
if (!['on', 'off', 'up', 'down', 'left', 'right'].includes(state)) throw new Error(`${where}:camera 要写 on、off、up、down、left 或 right`)
|
||||
return { kind: 'camera', state: state as Extract<Expectation, { kind: 'camera' }>['state'] }
|
||||
}
|
||||
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
|
||||
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
|
||||
if ('zigbee' in raw) return { kind: 'zigbee', index: Number(raw.zigbee), on: bool(raw.on, where, 'on') }
|
||||
|
||||
@@ -69,6 +81,7 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
|
||||
return { kind: 'matches', control, pattern: new RegExp(source), source }
|
||||
}
|
||||
if ('unchanged' in raw) return { kind: 'unchanged', control, ms: duration(raw.unchanged, where) }
|
||||
if ('image' in raw) return { kind: 'image', control }
|
||||
for (const [key, prop] of Object.entries(PROP_KEYS)) {
|
||||
if (key in raw) return { kind: 'prop', control, prop, value: STRING_PROPS.has(prop) ? String(raw[key]) : bool(raw[key], where, key) }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export class ProgramDriver {
|
||||
private readonly nodes = new Map<string, ControlNode>()
|
||||
private readonly parents = new Map<string, ControlNode>()
|
||||
readonly messageBoxes: { text: string; caption: string }[] = []
|
||||
/** 程序用 Image.Save 保存的图片路径 */
|
||||
readonly savedImages: string[] = []
|
||||
readonly exceptions: string[] = []
|
||||
console = ''
|
||||
exited = false
|
||||
@@ -106,6 +108,9 @@ export class ProgramDriver {
|
||||
case 'device':
|
||||
this.board.applyDevice(message)
|
||||
break
|
||||
case 'imageSaved':
|
||||
this.savedImages.push(message.path)
|
||||
break
|
||||
case 'console':
|
||||
this.console += message.text
|
||||
break
|
||||
|
||||
@@ -11,6 +11,9 @@ const START_TIMEOUT = 10_000
|
||||
|
||||
const RELAY_NAMES = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
|
||||
const ZIGBEE_NAMES = ['ZigBee 节点 0', '照明灯 1']
|
||||
/** 摄像头云台开始时的位置(见 IPCameraHelper.cs) */
|
||||
const CAMERA_START = { x: 3, y: 1 }
|
||||
const CAMERA_WORDS = { on: '开启(传送画面)', off: '关闭', up: '向上转', down: '向下转', left: '向左转', right: '向右转' }
|
||||
|
||||
// ---------- 并发限制 ----------
|
||||
let running = 0
|
||||
@@ -160,6 +163,14 @@ function satisfied(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'uncha
|
||||
return driver.messageBoxes.some((box) => box.text.includes(e.text))
|
||||
case 'console':
|
||||
return driver.console.includes(e.text)
|
||||
case 'image':
|
||||
return Boolean(props(e.control)?.Image)
|
||||
case 'camera': {
|
||||
const { on, x, y } = driver.board.state.camera
|
||||
return { on, off: !on, up: y < CAMERA_START.y, down: y > CAMERA_START.y, left: x < CAMERA_START.x, right: x > CAMERA_START.x }[e.state]
|
||||
}
|
||||
case 'savedImage':
|
||||
return driver.savedImages.some((path) => path.includes(e.path))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,5 +206,13 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
||||
}
|
||||
case 'console':
|
||||
return `期望${prefix}控制台${desc ?? `输出"${e.text}"`},实际输出${quote(driver.console.trim())}`
|
||||
case 'image':
|
||||
return `期望${prefix} ${e.control} ${desc ?? '显示图片'},实际是空的`
|
||||
case 'camera':
|
||||
return `期望${prefix}网络摄像机${desc ?? CAMERA_WORDS[e.state]},实际${driver.board.state.camera.on ? '开着' : '没有开启'}${e.state === 'on' || e.state === 'off' ? '' : '、云台没有朝这个方向转'}`
|
||||
case 'savedImage': {
|
||||
const saved = driver.savedImages.map((p) => `"${p}"`).join('、')
|
||||
return `期望${prefix}${desc ?? `保存图片到"${e.path}"`},${saved ? `实际保存的是 ${saved}` : '实际没有保存图片'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,8 @@ export class RunSession {
|
||||
clearTimeout(timeout)
|
||||
if (this.proc !== proc) return
|
||||
this.proc = undefined
|
||||
this.board.programExited()
|
||||
this.sendBoard()
|
||||
const cpuMessage = cpuLimitMessage(code, proc.signalCode)
|
||||
if (cpuMessage) this.send({ type: 'console', text: cpuMessage })
|
||||
this.send({ type: 'exited', code })
|
||||
@@ -159,6 +161,8 @@ export class RunSession {
|
||||
if (!proc) return
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
this.board.programExited()
|
||||
this.sendBoard()
|
||||
}
|
||||
|
||||
private async cleanup() {
|
||||
|
||||
@@ -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(12))
|
||||
test('每个示例都有评分规则', () => expect(ids.length).toBe(13))
|
||||
for (const id of ids) {
|
||||
test(id, async () => {
|
||||
const result = await grade(await prepare(id, id), await checksOf(id))
|
||||
@@ -80,6 +80,14 @@ describe('改坏的代码', () => {
|
||||
expect(stop.message).toStartWith('期望 txt_time 不再更新时间(定时器应已停止),实际从')
|
||||
}, 60000)
|
||||
|
||||
test('摄像机:忘了绑定画面事件', async () => {
|
||||
const id = 'p4-t2-网络摄像机'
|
||||
const dir = await prepare(id, 'camera', { file: 'Form1.cs', from: 'DeviceCtrlHelper.ipCameraHelper.ImageRead += ipCameraHelper_ImageRead;', to: '' })
|
||||
const result = await grade(dir, await checksOf(id))
|
||||
expect(result.results.map((r) => r.passed)).toEqual([true, false, true, false])
|
||||
expect(result.results[1].message).toBe('期望3 秒内 pictureBox1 显示摄像机拍到的图像,实际是空的')
|
||||
}, 60000)
|
||||
|
||||
test('关闭时忘了挂起线程', async () => {
|
||||
const id = 'p5-t2-烟雾'
|
||||
const dir = await prepare(id, 'thread', { file: 'Form1.cs', from: 'thd.Suspend();//挂起线程', to: '' })
|
||||
|
||||
@@ -318,6 +318,41 @@ test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async ()
|
||||
await even('发现入侵')
|
||||
}, 30000)
|
||||
|
||||
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
|
||||
const { runner, id } = await startSample('p4-t2-网络摄像机')
|
||||
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
|
||||
const imageOf = (patch: Extract<RunnerMessage, { type: 'patch' }>) =>
|
||||
patch.changes.find((c) => c.id === id('pictureBox1') && c.prop === 'Image')!.value as { key: string; width: number; height: number }
|
||||
|
||||
runner.send({ type: 'action', id: id('txt_ip'), action: 'text', value: '192.168.2.50' })
|
||||
const first = pictures()
|
||||
const cameraOn = runner.next('device', (m) => m.device === 'camera' && m.action === 'on' && m.data === true)
|
||||
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
|
||||
await cameraOn
|
||||
const center = imageOf(await first)
|
||||
expect(center).toMatchObject({ width: 640, height: 480 })
|
||||
// 图片数据先于(或随)属性变化发给浏览器,同一张图只发一次
|
||||
const sent = runner.messages.filter((m) => m.type === 'image' && m.key === center.key)
|
||||
expect(sent.length).toBe(1)
|
||||
expect(sent[0]).toMatchObject({ mime: 'image/jpeg' })
|
||||
|
||||
const up = pictures()
|
||||
const pan = runner.next('device', (m) => m.device === 'camera' && m.action === 'pan')
|
||||
runner.send({ type: 'action', id: id('btn_up'), action: 'click' })
|
||||
expect((await pan).data).toEqual([3, 0])
|
||||
expect(imageOf(await up).key).not.toBe(center.key)
|
||||
|
||||
const intruder = pictures()
|
||||
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
|
||||
const withIntruder = imageOf(await intruder)
|
||||
|
||||
const saved = runner.next('imageSaved')
|
||||
runner.send({ type: 'action', id: id('btn_camera'), action: 'click' })
|
||||
const photo = await saved
|
||||
expect(photo.path).toMatch(/^Camera\/\d{8}_\d{2} \d{2} \d{2}\.jpg$/)
|
||||
expect(photo.image).toBe(withIntruder.key)
|
||||
}, 30000)
|
||||
|
||||
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
|
||||
const { runner, id } = await startSample('p4-t3-报警灯')
|
||||
await openAdam4150(runner, id)
|
||||
@@ -483,6 +518,32 @@ test('ListBox:点某一项先选中(SelectedIndexChanged)再触发 Click
|
||||
expect(output).toBe('changed=1,乙\nclick\ndouble=乙\n')
|
||||
}, 30000)
|
||||
|
||||
test('图片:程序所在目录可写,Image.Save / FromFile 往返,文件夹不存在时和 GDI+ 一样报一般性错误', async () => {
|
||||
const { runner } = await runMain(
|
||||
'image',
|
||||
`
|
||||
string dir = AppDomain.CurrentDomain.BaseDirectory;
|
||||
Console.WriteLine("base=" + dir.EndsWith("/bin/Debug/") + "," + (Directory.GetCurrentDirectory() + "/" == dir));
|
||||
// 1×1 的 PNG
|
||||
byte[] png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==");
|
||||
Image image = Image.FromStream(new MemoryStream(png));
|
||||
Directory.CreateDirectory(dir + "Pics");
|
||||
image.Save(dir + "Pics/a.png", ImageFormat.Png);
|
||||
Image back = Image.FromFile("Pics/a.png");
|
||||
Console.WriteLine("size=" + back.Width + "x" + back.Height + "," + back.RawFormat);
|
||||
try { image.Save(dir + "NoSuchDir/b.png"); }
|
||||
catch (System.Runtime.InteropServices.ExternalException ex) { Console.WriteLine("error=" + ex.Message); }`,
|
||||
'using System.Drawing;\nusing System.Drawing.Imaging;',
|
||||
)
|
||||
expect((await runner.waitFor('compiled')).diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
||||
await runner.finished()
|
||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||
expect(output).toContain('base=True,True')
|
||||
expect(output).toContain('size=1x1,Png')
|
||||
expect(output).toContain('error=GDI+ 中发生一般性错误。')
|
||||
expect(runner.messages.filter((m) => m.type === 'imageSaved').map((m) => (m as { path: string }).path)).toEqual(['Pics/a.png'])
|
||||
}, 30000)
|
||||
|
||||
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
|
||||
process.env.SIM_TEST_SECRET = '不该被看到'
|
||||
const { runner } = await runMain(
|
||||
@@ -493,7 +554,8 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别
|
||||
Console.WriteLine("home=" + Directory.Exists("/home"));
|
||||
Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null"));
|
||||
Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0])));
|
||||
try { File.WriteAllText("hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
|
||||
// 工程目录在沙箱里挂在 /sim/app(当前目录是可写的程序目录 bin/Debug,不是工程目录)
|
||||
try { File.WriteAllText("/sim/app/hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
|
||||
File.WriteAllText("/tmp/ok.txt", "x");
|
||||
Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`,
|
||||
)
|
||||
@@ -525,7 +587,7 @@ test('内存上限:不停分配内存的程序得到 OutOfMemoryException,
|
||||
expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb)
|
||||
}, 30000)
|
||||
|
||||
test('编译检查:网络、启动程序、反射、dynamic 给出中文报错;AssemblyInfo.cs 里的特性不受影响', async () => {
|
||||
test('编译检查:网络、启动程序、反射、dynamic、AppDomain 给出中文报错;AssemblyInfo.cs 里的特性、BaseDirectory 不受影响', async () => {
|
||||
const dir = resolve(ROOT, '.runs/test-policy')
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await Bun.write(
|
||||
@@ -541,6 +603,8 @@ static class Program
|
||||
var client = new HttpClient();
|
||||
typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null);
|
||||
dynamic d = 1;
|
||||
string path = AppDomain.CurrentDomain.BaseDirectory + "Camera/";
|
||||
AppDomain.CurrentDomain.GetAssemblies();
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -562,6 +626,7 @@ using System.Runtime.InteropServices;
|
||||
[9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'],
|
||||
[10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'],
|
||||
[11, '平台不允许使用 dynamic(dynamic 动态调用)'],
|
||||
[13, '平台不允许使用 System.AppDomain.GetAssemblies(加载程序集)'],
|
||||
])
|
||||
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
|
||||
}, 30000)
|
||||
|
||||
Reference in new issue
Block a user