refactor: extract boxing logic into dedicated modules with tests

- boxing_models.dart: add BoxingMode, BoxingPhase, BoxingPageArguments
- boxing_box_mutations.dart: box item add/replace/remove operations
- boxing_calculations.dart: pure calculation helpers (quantities, validation)
- boxing_status_presenter.dart: status bar text/dot determination
- dialogs/cross_paichan_dialog.dart: cross-paichan confirmation dialog
- widgets/boxing_notice_banners.dart: notice banner widgets
- Add unit tests for mutations, calculations, and status presenter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-05-21 09:02:56 +08:00
parent f2b2f41929
commit c2d9f5fd56
10 changed files with 1066 additions and 492 deletions

View File

@@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_box_mutations.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('boxing box mutations', () {
test('adds item to a new sorted box', () {
final result = addExistingBoxItem(
existingBoxes: [BoxDetailData(boxNo: 3, items: const [])],
boxNo: 2,
quantity: 5,
boxItemId: 22,
zongpaiNo: 'ZP022',
workOrderNo: 'WO022',
totalQuantity: 5,
);
expect(result.boxes.map((box) => box.boxNo), [2, 3]);
expect(result.maxBoxNo, 3);
expect(result.boxes.first.items.single.zongpaiNo, 'ZP022');
});
test('replaces single code item and removes empty source box', () {
final result = replaceExistingBoxItem(
existingBoxes: [
BoxDetailData(
boxNo: 1,
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
),
],
editing: CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 2),
boxNo: 4,
quantity: 3,
zongpaiNo: 'ZP001',
workOrderNo: 'WO001',
totalQuantity: 5,
);
expect(result.boxes.map((box) => box.boxNo), [4]);
expect(result.boxes.single.items.single.quantity, 3);
expect(result.maxBoxNo, 4);
});
test('moves packed item to a different box', () {
final result = replaceExistingPackedItem(
existingBoxes: [
BoxDetailData(
boxNo: 2,
items: [BoxItemData(boxItemId: 9, zongpaiNo: 'ZP009', quantity: 1)],
),
],
item: const ManyToOnePackedItem(
boxItemId: 9,
zongpaiNo: 'ZP009',
workOrderNo: 'WO009',
boxNo: 2,
quantity: 1,
totalQuantity: 3,
),
boxNo: 5,
quantity: 2,
);
expect(result.boxes.map((box) => box.boxNo), [5]);
expect(result.boxes.single.items.single.quantity, 2);
});
test('removes item and drops empty box', () {
final result = removeExistingBoxItem(
existingBoxes: [
BoxDetailData(
boxNo: 1,
items: [BoxItemData(boxItemId: 1, zongpaiNo: 'ZP001', quantity: 2)],
),
BoxDetailData(
boxNo: 3,
items: [BoxItemData(boxItemId: 2, zongpaiNo: 'ZP002', quantity: 1)],
),
],
boxItemId: 1,
);
expect(result.boxes.map((box) => box.boxNo), [3]);
expect(result.maxBoxNo, 3);
});
});
}

View File

@@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_calculations.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/services/api_service.dart';
void main() {
group('boxing calculations', () {
final currentBoxes = [
CurrentZongpaiBoxData(boxItemId: 1, boxNo: 1, quantity: 4),
CurrentZongpaiBoxData(boxItemId: 2, boxNo: 2, quantity: 3),
];
test('calculates packed and remaining quantity', () {
expect(packedQuantity(currentBoxes), 7);
expect(
remainingQuantity(totalQuantity: 10, currentBoxes: currentBoxes),
3,
);
});
test('detects quantity too high only when remaining is positive', () {
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '4'), isTrue);
expect(quantityTooHigh(remainingQuantity: 3, quantityText: '3'), isFalse);
expect(quantityTooHigh(remainingQuantity: 0, quantityText: '1'), isFalse);
});
test('validates submit conditions', () {
expect(
canSubmitBoxing(
isSubmitting: false,
phase: BoxingPhase.scanned,
zongpaiNo: 'ZP001',
boxNoText: '1',
quantityText: '2',
remainingQuantity: 3,
quantityTooHigh: false,
isDuplicateBoxNo: false,
),
isTrue,
);
expect(
canSubmitBoxing(
isSubmitting: true,
phase: BoxingPhase.scanned,
zongpaiNo: 'ZP001',
boxNoText: '1',
quantityText: '2',
remainingQuantity: 3,
quantityTooHigh: false,
isDuplicateBoxNo: false,
),
isFalse,
);
});
test('detects duplicate box number in single code mode', () {
expect(
singleCodeBoxNoIsDuplicate(
boxNoText: '1',
currentBoxes: currentBoxes,
editingBoxItemId: null,
),
isTrue,
);
expect(
singleCodeBoxNoIsDuplicate(
boxNoText: '1',
currentBoxes: currentBoxes,
editingBoxItemId: 1,
),
isFalse,
);
});
test('merges visible many-to-one items by box item id', () {
final existingBoxes = [
BoxDetailData(
boxNo: 8,
items: [
BoxItemData(
boxItemId: 11,
zongpaiNo: 'ZP011',
workOrderNo: 'WO011',
quantity: 2,
totalQuantity: 6,
),
],
),
];
final packedItems = [
const ManyToOnePackedItem(
boxItemId: 11,
zongpaiNo: 'ZP011',
paichanNo: 'PC001',
workOrderNo: 'WO011',
boxNo: 8,
quantity: 3,
totalQuantity: 6,
),
const ManyToOnePackedItem(
boxItemId: 12,
zongpaiNo: 'ZP012',
paichanNo: 'PC001',
workOrderNo: 'WO012',
boxNo: 8,
quantity: 1,
totalQuantity: 4,
),
];
final visible = visibleManyToOnePackedItems(
boxNo: 8,
existingBoxes: existingBoxes,
packedItems: packedItems,
paichanNo: 'PC001',
);
expect(visible.map((item) => item.boxItemId), [11, 12]);
expect(visible.first.quantity, 3);
});
});
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pad_scanner/pages/boxing/boxing_models.dart';
import 'package:pad_scanner/pages/boxing/boxing_status_presenter.dart';
import 'package:pad_scanner/widgets/status_bar.dart';
void main() {
BoxingStatusPresentation status({
bool isAutoProcessing = false,
bool isSubmitting = false,
bool isDuplicateBoxNo = false,
bool quantityTooHigh = false,
bool isEditingAssigned = false,
bool isDeletingAssigned = false,
bool hasPaichanSwitchNotice = false,
BoxingPhase phase = BoxingPhase.waiting,
bool isMultiCode = false,
bool hasVisibleManyToOneItems = false,
int remainingQuantity = 1,
String boxNoText = '1',
}) {
return boxingStatusFor(
isAutoProcessing: isAutoProcessing,
isSubmitting: isSubmitting,
isDuplicateBoxNo: isDuplicateBoxNo,
quantityTooHigh: quantityTooHigh,
isEditingAssigned: isEditingAssigned,
isDeletingAssigned: isDeletingAssigned,
hasPaichanSwitchNotice: hasPaichanSwitchNotice,
phase: phase,
isMultiCode: isMultiCode,
hasVisibleManyToOneItems: hasVisibleManyToOneItems,
remainingQuantity: remainingQuantity,
boxNoText: boxNoText,
);
}
group('boxingStatusFor', () {
test('reports waiting and multi-code continuation states', () {
expect(status().text, '等待扫码');
final multi = status(isMultiCode: true, hasVisibleManyToOneItems: true);
expect(multi.text, '请继续扫码或完成本箱');
});
test('prioritizes transient and validation states', () {
expect(status(isSubmitting: true).text, '正在提交…');
expect(
status(isDuplicateBoxNo: true, phase: BoxingPhase.scanned).dot,
StatusDotColor.amber,
);
expect(
status(quantityTooHigh: true, phase: BoxingPhase.scanned).text,
'超出可装数量上限',
);
});
test('reports edit delete and switched paichan states', () {
expect(status(isEditingAssigned: true).text, '正在编辑已分配记录');
expect(status(isDeletingAssigned: true).dot, StatusDotColor.red);
expect(status(hasPaichanSwitchNotice: true).text, '排产号已切换,箱号已重置');
});
test('reports scanned completed and submitted states', () {
expect(
status(phase: BoxingPhase.scanned, remainingQuantity: 0).text,
'该总排号已全部装箱完毕',
);
expect(
status(phase: BoxingPhase.scanned, remainingQuantity: 2).text,
'数量已填入,请确认或修改',
);
expect(
status(phase: BoxingPhase.submitted, isMultiCode: false).text,
'装箱成功,可继续扫码',
);
expect(
status(phase: BoxingPhase.submitted, isMultiCode: true).text,
'请扫描下一个总排号',
);
});
});
}