feat: unified shell, diagnostics pack, native Office, sticky board
All checks were successful
CI / Windows build (push) Successful in 14m22s

Make Surface remote debugging and classroom workflows viable: always-on
structured logs with one-click zip export, a single AppShell chrome,
OOXML PPTX/DOCX annotation without LibreOffice, and a first-class sticky
board. Also drop spike/legacy ink widgets and tighten pen feel
(predictor, PenInfoHistory, page-tile layer).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 17:55:27 +08:00
parent 3cabc7e074
commit d346cc2670
49 changed files with 2883 additions and 2515 deletions

View File

@@ -0,0 +1,36 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/diagnostics/badnote_log.dart';
import 'package:badnote/diagnostics/pen_event_ring.dart';
import 'package:badnote/diagnostics/frame_sampler.dart';
void main() {
test('PenEventRing retains recent events', () {
final ring = PenEventRing.instance;
ring.clear();
ring.recordArbiter(
activeCount: 1,
deviceKind: 'stylus',
draw: true,
fingerDrawing: false,
);
expect(ring.recent(), isNotEmpty);
expect(ring.toJsonList().first['decision'], 'draw');
});
test('FrameSampler flags over-budget frames', () {
FrameSampler.instance.reset();
FrameSampler.instance.record('test', 8);
FrameSampler.instance.record('slow', 30);
expect(FrameSampler.instance.overBudget, 1);
expect(FrameSampler.instance.summary()['total'], 2);
});
test('BadNoteLog ring accepts entries without start', () {
BadNoteLog.instance.info(LogSubsystem.diag, 'unit_test_ping');
expect(
BadNoteLog.instance.snapshotRing().any((e) => e['msg'] == 'unit_test_ping'),
isTrue,
);
});
}

View File

@@ -0,0 +1,66 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/services/office/docx_parser.dart';
import 'package:badnote/services/office/pptx_parser.dart';
ArchiveFile _xml(String name, String body) {
final bytes = Uint8List.fromList(body.codeUnits);
return ArchiveFile(name, bytes.length, bytes);
}
void main() {
test('PptxParser extracts slide text from minimal OOXML', () async {
final archive = Archive();
archive.addFile(_xml(
'[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>',
));
archive.addFile(_xml(
'ppt/presentation.xml',
'<?xml version="1.0"?><p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:sldSz cx="12192000" cy="6858000"/></p:presentation>',
));
archive.addFile(_xml(
'ppt/slides/slide1.xml',
'<?xml version="1.0"?><p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
'<p:cSld><p:spTree><p:sp><p:txBody><a:p><a:r><a:t>Hello Slide</a:t></a:r></a:p></p:txBody></p:sp>'
'</p:spTree></p:cSld></p:sld>',
));
final encoded = ZipEncoder().encode(archive)!;
final dir = await Directory.systemTemp.createTemp('pptx_test_');
final path = '${dir.path}/t.pptx';
await File(path).writeAsBytes(encoded);
final parsed = await PptxParser().parse(path);
expect(parsed.slides, isNotEmpty);
expect(parsed.plainText, contains('Hello Slide'));
await dir.delete(recursive: true);
});
test('DocxParser extracts paragraphs from minimal OOXML', () async {
final archive = Archive();
archive.addFile(_xml(
'[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>',
));
archive.addFile(_xml(
'word/document.xml',
'<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
'<w:body><w:p><w:r><w:t>Linear Algebra</w:t></w:r></w:p></w:body></w:document>',
));
final encoded = ZipEncoder().encode(archive)!;
final dir = await Directory.systemTemp.createTemp('docx_test_');
final path = '${dir.path}/t.docx';
await File(path).writeAsBytes(encoded);
final parsed = await DocxParser().parse(path);
expect(parsed.plainText, contains('Linear Algebra'));
await dir.delete(recursive: true);
});
}

View File

@@ -0,0 +1,42 @@
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/pdf/page_tile_cache.dart';
import 'package:badnote/editor/pdf/page_tile_layer.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('PageTileCache LRU evicts oldest', () async {
final cache = PageTileCache(maxTiles: 2);
final a = await _tinyImage();
final b = await _tinyImage();
final c = await _tinyImage();
cache.put(const TileKey('p1', 96), a);
cache.put(const TileKey('p2', 96), b);
expect(cache.length, 2);
cache.put(const TileKey('p3', 96), c);
expect(cache.length, 2);
expect(cache.get(const TileKey('p1', 96)), isNull);
});
test('PageTileLayer falls back to last good', () async {
final layer = PageTileLayer(cache: PageTileCache(maxTiles: 4));
final img = await _tinyImage();
layer.put(const TileKey('p1', 96), img);
expect(layer.resolve(const TileKey('p1', 192)), same(img));
// Don't call dispose() in unit test — cache dispose needs a frame.
});
}
Future<ui.Image> _tinyImage() async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(
const ui.Rect.fromLTWH(0, 0, 2, 2),
ui.Paint()..color = const ui.Color(0xFFFFFFFF),
);
final picture = recorder.endRecording();
return picture.toImage(2, 2);
}

View File

@@ -0,0 +1,28 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/stroke_predictor.dart';
void main() {
test('StrokePredictor projects ahead when velocity is high', () {
final p = StrokePredictor(lookaheadMs: 16);
final t0 = DateTime.utc(2026, 1, 1, 0, 0, 0, 0);
expect(p.observe(const Offset(0, 0), 0.5, at: t0), isNull);
final tip = p.observe(
const Offset(10, 0),
0.5,
at: t0.add(const Duration(milliseconds: 8)),
);
// 10px / 8ms ≈ 1250 px/s → 16ms lookahead ≈ 20px ahead
expect(tip, isNotNull);
expect(tip!.offset.dx, greaterThan(10));
});
test('StrokePredictor resets cleanly', () {
final p = StrokePredictor();
p.observe(const Offset(0, 0), 0.4);
p.reset();
expect(p.observe(const Offset(1, 1), 0.4), isNull);
});
}