diff --git a/test/forum_models_test.dart b/test/forum_models_test.dart new file mode 100644 index 0000000..802fd42 --- /dev/null +++ b/test/forum_models_test.dart @@ -0,0 +1,335 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:VosPass/models/forum_models.dart'; + +void main() { + group('ForumUser', () { + test('fromJson parses all fields', () { + final json = { + 'id': 'fu1', + 'name': 'Alice', + 'username': 'alice123', + 'avatar': 'https://img/alice.png', + }; + final user = ForumUser.fromJson(json); + expect(user.id, 'fu1'); + expect(user.name, 'Alice'); + expect(user.username, 'alice123'); + expect(user.avatarURL, 'https://img/alice.png'); + expect(user.displayName, 'Alice'); + }); + + test('displayName falls back to username when name is empty', () { + final json = {'name': '', 'username': 'bob456'}; + final user = ForumUser.fromJson(json); + expect(user.displayName, 'bob456'); + }); + + test('id defaults to username when not provided', () { + final json = {'name': 'Carol', 'username': 'carol789'}; + final user = ForumUser.fromJson(json); + expect(user.id, 'carol789'); + }); + + test('avatarURL returns null for empty avatar', () { + final json = {'name': 'Dave', 'username': 'dave', 'avatar': ''}; + final user = ForumUser.fromJson(json); + expect(user.avatarURL, isNull); + }); + + test('avatarURL returns null when avatar is not provided', () { + final json = {'name': 'Eve', 'username': 'eve'}; + final user = ForumUser.fromJson(json); + expect(user.avatarURL, isNull); + }); + }); + + group('ForumPost', () { + test('fromJson parses complete post', () { + final json = { + 'id': 'p1', + 'post': 'p1-post', + 'school': '鶯歌工商', + 'title': 'Hello World', + 'content': 'This is content', + 'anonymous': false, + 'pin': true, + 'tag': { + 'news': {'color': '#FF0000', 'admin_only': true}, + 'general': {'color': '#00FF00'}, + }, + 'images': ['https://img/1.png', 'https://img/2.png'], + 'likes': ['user1', 'user2'], + 'user': {'name': 'Alice', 'username': 'alice'}, + 'created': '2024-01-01T00:00:00Z', + 'updated': '2024-01-02T00:00:00Z', + }; + final post = ForumPost.fromJson(json); + expect(post.id, 'p1'); + expect(post.post, 'p1-post'); + expect(post.school, '鶯歌工商'); + expect(post.title, 'Hello World'); + expect(post.content, 'This is content'); + expect(post.anonymous, false); + expect(post.pin, true); + expect(post.tags.length, 2); + expect(post.images.length, 2); + expect(post.likes, ['user1', 'user2']); + expect(post.user!.name, 'Alice'); + expect(post.created, '2024-01-01T00:00:00Z'); + }); + + test('tags are sorted by name', () { + final json = { + 'id': 'p2', + 'school': '', + 'title': '', + 'content': '', + 'tag': { + 'z_tag': {}, + 'a_tag': {}, + 'm_tag': {}, + }, + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.tags.map((t) => t.name).toList(), ['a_tag', 'm_tag', 'z_tag']); + }); + + test('tags with admin_only parsed correctly', () { + final json = { + 'id': 'p3', + 'school': '', + 'title': '', + 'content': '', + 'tag': { + 'admin': {'color': '#FFF', 'admin_only': true}, + 'public': {'color': '#000', 'admin_only': false}, + }, + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + final adminTag = post.tags.firstWhere((t) => t.name == 'admin'); + final publicTag = post.tags.firstWhere((t) => t.name == 'public'); + expect(adminTag.adminOnly, true); + expect(publicTag.adminOnly, false); + }); + + test('title defaults to 未命名文章 when empty', () { + final json = { + 'id': 'p4', + 'school': '', + 'content': '', + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.title, '未命名文章'); + }); + + test('likeTargetID returns post when available', () { + final json = { + 'id': 'p5', + 'post': 'post-id', + 'school': '', + 'title': '', + 'content': '', + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.likeTargetID, 'post-id'); + }); + + test('likeTargetID falls back to id when post is null', () { + final json = { + 'id': 'p6', + 'school': '', + 'title': '', + 'content': '', + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.likeTargetID, 'p6'); + }); + + test('imageURLs filters empty strings', () { + final json = { + 'id': 'p7', + 'school': '', + 'title': '', + 'content': '', + 'images': ['https://img/1.png', '', 'https://img/2.png'], + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.imageURLs, ['https://img/1.png', 'https://img/2.png']); + }); + + test('isLikedBy works correctly', () { + final json = { + 'id': 'p8', + 'school': '', + 'title': '', + 'content': '', + 'likes': ['user1', 'user2'], + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.isLikedBy('user1'), true); + expect(post.isLikedBy('user3'), false); + expect(post.isLikedBy(null), false); + expect(post.isLikedBy(''), false); + }); + + test('user is null when not a map', () { + final json = { + 'id': 'p9', + 'school': '', + 'title': '', + 'content': '', + 'user': 'not a map', + 'created': '', + 'updated': '', + }; + final post = ForumPost.fromJson(json); + expect(post.user, isNull); + }); + }); + + group('ForumMessage', () { + test('fromJson parses complete message', () { + final json = { + 'id': 'msg1', + 'content': 'Hello!', + 'anonymous': true, + 'user': {'name': 'Alice', 'username': 'alice'}, + 'created': '2024-01-01', + 'likes': ['u1'], + }; + final msg = ForumMessage.fromJson(json); + expect(msg.id, 'msg1'); + expect(msg.content, 'Hello!'); + expect(msg.anonymous, true); + expect(msg.user!.name, 'Alice'); + expect(msg.created, '2024-01-01'); + expect(msg.likes, ['u1']); + }); + + test('isLikedBy works correctly', () { + final json = { + 'id': 'msg2', + 'content': '', + 'likes': ['u1', 'u2'], + 'created': '', + }; + final msg = ForumMessage.fromJson(json); + expect(msg.isLikedBy('u1'), true); + expect(msg.isLikedBy('u3'), false); + expect(msg.isLikedBy(null), false); + expect(msg.isLikedBy(''), false); + }); + + test('fromJson reads alternative content keys', () { + final json = { + 'id': 'msg3', + 'body': 'Body content', + 'created': '', + }; + final msg = ForumMessage.fromJson(json); + expect(msg.content, 'Body content'); + }); + }); + + group('ForumPostListData', () { + test('fromJson parses forums list and total pages', () { + final json = { + 'forums': [ + { + 'id': 'p1', + 'school': 's', + 'title': 'T1', + 'content': 'C1', + 'created': '', + 'updated': '', + }, + { + 'id': 'p2', + 'school': 's', + 'title': 'T2', + 'content': 'C2', + 'created': '', + 'updated': '', + }, + ], + 'total_pages': 5, + }; + final data = ForumPostListData.fromJson(json); + expect(data.forums.length, 2); + expect(data.forums[0].id, 'p1'); + expect(data.totalPages, 5); + }); + + test('fromJson handles missing forums', () { + final json = {}; + final data = ForumPostListData.fromJson(json); + expect(data.forums, isEmpty); + expect(data.totalPages, 1); + }); + }); + + group('ForumMessageListData', () { + test('fromJson parses messages list', () { + final json = { + 'forums': [ + {'id': 'm1', 'content': 'C1', 'created': ''}, + ], + 'totalPages': 3, + }; + final data = ForumMessageListData.fromJson(json); + expect(data.forums.length, 1); + expect(data.totalPages, 3); + }); + }); + + group('ForumAdminInfo', () { + test('fromJson parses all fields', () { + final json = { + 'id': 'admin1', + 'school': '鶯歌工商', + 'icon': 'https://img/admin.png', + 'admin': ['u1', 'u2'], + }; + final info = ForumAdminInfo.fromJson(json); + expect(info.id, 'admin1'); + expect(info.school, '鶯歌工商'); + expect(info.iconURL, 'https://img/admin.png'); + expect(info.admin, ['u1', 'u2']); + }); + + test('iconURL returns null for empty icon', () { + final json = { + 'id': 'admin2', + 'school': 's', + 'icon': '', + 'admin': [], + }; + final info = ForumAdminInfo.fromJson(json); + expect(info.iconURL, isNull); + }); + + test('iconURL returns null when icon is not provided', () { + final json = { + 'id': 'admin3', + 'school': 's', + 'admin': [], + }; + final info = ForumAdminInfo.fromJson(json); + expect(info.iconURL, isNull); + }); + }); +} diff --git a/test/html_parser_test.dart b/test/html_parser_test.dart new file mode 100644 index 0000000..f212ba0 --- /dev/null +++ b/test/html_parser_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:VosPass/services/html_parser.dart'; + +void main() { + group('HtmlParser.parseExamScores', () { + test('parses student ID from HTML', () { + const html = '
學號:12345678
'; + final data = HtmlParser.parseExamScores(html); + expect(data.studentInfo.studentId, '12345678'); + }); + + test('parses exam info from bluetext span', () { + const html = '112-1 第一次段考'; + final data = HtmlParser.parseExamScores(html); + expect(data.examInfo, '112-1 第一次段考'); + }); + + test('parses subject scores from Table1', () { + const html = ''' + + + + + +
科目個人成績班級平均
國文8572
英文9075
數學7870
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects.length, 3); + expect(data.subjects[0].subject, '國文'); + expect(data.subjects[0].personalScore, '85'); + expect(data.subjects[0].classAverage, '72'); + expect(data.subjects[1].subject, '英文'); + expect(data.subjects[2].subject, '數學'); + }); + + test('skips rows with fewer than 3 cells', () { + const html = ''' + + + + + +
SubjectScoreAvg
國文8572
Math90
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects.length, 1); + expect(data.subjects[0].subject, '國文'); + }); + + test('handles HTML entities in content', () { + const html = ''' + + + +
科目成績平均
Art & Design9580
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, 'Art & Design'); + }); + + test('handles decimal HTML entities', () { + const html = ''' + + + +
XYZ
A9080
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, 'A'); + }); + + test('handles hex HTML entities', () { + const html = ''' + + + +
XYZ
B9080
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, 'B'); + }); + + test('strips nested HTML tags', () { + const html = ''' + + + +
XYZ
Bold9080
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, 'Bold'); + }); + + test('handles   entity', () { + const html = ''' + + + +
XYZ
Hello World9080
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, 'Hello World'); + }); + + test('returns empty data for empty HTML', () { + final data = HtmlParser.parseExamScores(''); + expect(data.subjects, isEmpty); + expect(data.examInfo, ''); + }); + + test('parses complete page with all sections', () { + const html = ''' +
學號:99999
+113-1 期中考 + + + + +
科目成績平均
物理8865
化學9270
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.studentInfo.studentId, '99999'); + expect(data.examInfo, '113-1 期中考'); + expect(data.subjects.length, 2); + }); + + test('handles < > " ' entities', () { + const html = ''' + + + +
XYZ
<tag>"val"'x'
+'''; + final data = HtmlParser.parseExamScores(html); + expect(data.subjects[0].subject, ''); + expect(data.subjects[0].personalScore, '"val"'); + expect(data.subjects[0].classAverage, "'x'"); + }); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart new file mode 100644 index 0000000..0297bd1 --- /dev/null +++ b/test/models_test.dart @@ -0,0 +1,950 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:VosPass/models/models.dart'; + +void main() { + group('JsonUtils', () { + group('readString', () { + test('returns value for first matching key', () { + final json = {'name': 'Alice', 'full_name': 'Alice B.'}; + expect(JsonUtils.readString(json, ['name']), 'Alice'); + }); + + test('falls back to next key when first is missing', () { + final json = {'full_name': 'Alice B.'}; + expect(JsonUtils.readString(json, ['name', 'full_name']), 'Alice B.'); + }); + + test('returns defaultValue when no key matches', () { + final json = {}; + expect(JsonUtils.readString(json, ['name'], defaultValue: 'N/A'), 'N/A'); + }); + + test('converts num to string', () { + final json = {'score': 95}; + expect(JsonUtils.readString(json, ['score']), '95'); + }); + + test('converts bool to string', () { + final json = {'active': true}; + expect(JsonUtils.readString(json, ['active']), 'true'); + }); + + test('skips null values', () { + final json = {'name': null, 'fallback': 'ok'}; + expect(JsonUtils.readString(json, ['name', 'fallback']), 'ok'); + }); + }); + + group('readStringNullable', () { + test('returns null for empty string result', () { + final json = {}; + expect(JsonUtils.readStringNullable(json, ['x']), isNull); + }); + + test('returns value when present', () { + final json = {'x': 'hello'}; + expect(JsonUtils.readStringNullable(json, ['x']), 'hello'); + }); + }); + + group('readInt', () { + test('reads int value', () { + final json = {'count': 42}; + expect(JsonUtils.readInt(json, ['count']), 42); + }); + + test('converts double to int', () { + final json = {'count': 3.7}; + expect(JsonUtils.readInt(json, ['count']), 3); + }); + + test('parses string to int', () { + final json = {'count': ' 10 '}; + expect(JsonUtils.readInt(json, ['count']), 10); + }); + + test('converts bool to int', () { + final json = {'flag': true}; + expect(JsonUtils.readInt(json, ['flag']), 1); + final json2 = {'flag': false}; + expect(JsonUtils.readInt(json2, ['flag']), 0); + }); + + test('returns defaultValue when no key matches', () { + final json = {}; + expect(JsonUtils.readInt(json, ['count'], defaultValue: -1), -1); + }); + + test('returns defaultValue for unparseable string', () { + final json = {'count': 'abc'}; + expect(JsonUtils.readInt(json, ['count'], defaultValue: 5), 5); + }); + }); + + group('readBool', () { + test('reads bool value directly', () { + final json = {'active': true}; + expect(JsonUtils.readBool(json, ['active']), true); + }); + + test('converts int to bool', () { + final json = {'active': 1}; + expect(JsonUtils.readBool(json, ['active']), true); + final json2 = {'active': 0}; + expect(JsonUtils.readBool(json2, ['active']), false); + }); + + test('parses truthy strings', () { + for (final v in ['true', '1', 'yes', 'y', 'ok', 'success']) { + final json = {'val': v}; + expect(JsonUtils.readBool(json, ['val']), true, reason: 'Expected $v to be true'); + } + }); + + test('parses falsy strings', () { + for (final v in ['false', '0', 'no', 'n', 'random']) { + final json = {'val': v}; + expect(JsonUtils.readBool(json, ['val']), false, reason: 'Expected $v to be false'); + } + }); + + test('is case insensitive', () { + final json = {'val': ' TRUE '}; + expect(JsonUtils.readBool(json, ['val']), true); + }); + + test('returns defaultValue when no key matches', () { + final json = {}; + expect(JsonUtils.readBool(json, ['active'], defaultValue: true), true); + }); + }); + + group('readStringList', () { + test('reads list of strings', () { + final json = { + 'tags': ['a', 'b', 'c'] + }; + expect(JsonUtils.readStringList(json, ['tags']), ['a', 'b', 'c']); + }); + + test('filters empty entries and trims', () { + final json = { + 'tags': [' hello ', '', ' ', 'world'] + }; + expect(JsonUtils.readStringList(json, ['tags']), ['hello', 'world']); + }); + + test('parses comma-separated string', () { + final json = {'tags': 'a, b, c'}; + expect(JsonUtils.readStringList(json, ['tags']), ['a', 'b', 'c']); + }); + + test('returns default when no key matches', () { + final json = {}; + expect(JsonUtils.readStringList(json, ['tags']), isEmpty); + }); + }); + + group('readStringMap', () { + test('reads map and converts values to string', () { + final json = { + 'data': {'a': 1, 'b': 'hello'} + }; + expect( + JsonUtils.readStringMap(json, ['data']), + {'a': '1', 'b': 'hello'}, + ); + }); + + test('returns default when no key matches', () { + final json = {}; + expect(JsonUtils.readStringMap(json, ['data']), isEmpty); + }); + }); + }); + + group('ApiResponse', () { + test('parses standard response with code, message, data', () { + final json = { + 'code': 200, + 'message': 'OK', + 'data': {'key': 'value'}, + }; + final response = ApiResponse.fromJson(json, (d) => d); + expect(response.code, 200); + expect(response.message, 'OK'); + expect(response.data, {'key': 'value'}); + }); + + test('resolves alternative keys (status, msg, result)', () { + final json = { + 'status': 404, + 'msg': 'Not Found', + 'result': 'some data', + }; + final response = ApiResponse.fromJson(json, (d) => d.toString()); + expect(response.code, 404); + expect(response.message, 'Not Found'); + expect(response.data, 'some data'); + }); + + test('resolves success boolean field', () { + final json = { + 'success': false, + 'detail': 'fail reason', + 'payload': 123, + }; + final response = ApiResponse.fromJson(json, (d) => d as int); + expect(response.code, 0); + expect(response.message, 'fail reason'); + expect(response.data, 123); + }); + + test('handles non-map json input', () { + final response = ApiResponse.fromJson('raw string', (d) => d.toString()); + expect(response.code, 200); + expect(response.data, 'raw string'); + }); + + test('falls back to the entire map as data when no data/result/payload key', () { + final json = {'code': 200, 'message': 'ok', 'name': 'test'}; + final response = ApiResponse.fromJson(json, (d) => d); + expect((response.data as Map)['name'], 'test'); + }); + }); + + group('MeritDemeritRecord', () { + test('fromJson parses all fields', () { + final json = { + 'date_occurred': '2024-01-15', + 'date_approved': '2024-01-20', + 'reason': 'Good behavior', + 'action': '小功', + 'date_revoked': '2024-02-01', + 'year': '112', + }; + final record = MeritDemeritRecord.fromJson(json); + expect(record.dateOccurred, '2024-01-15'); + expect(record.dateApproved, '2024-01-20'); + expect(record.reason, 'Good behavior'); + expect(record.action, '小功'); + expect(record.dateRevoked, '2024-02-01'); + expect(record.year, '112'); + expect(record.id, '2024-01-15-小功-Good behavior-2024-01-20'); + }); + + test('fromJson with alternative keys', () { + final json = { + 'dateOccurred': '2024-03-01', + 'dateApproved': '2024-03-05', + 'reason': 'test', + 'content': '嘉獎', + 'school_year': '113', + }; + final record = MeritDemeritRecord.fromJson(json); + expect(record.dateOccurred, '2024-03-01'); + expect(record.action, '嘉獎'); + expect(record.year, '113'); + expect(record.dateRevoked, isNull); + }); + }); + + group('AbsenceRecord', () { + test('fromJson parses all fields', () { + final json = { + 'academic_term': '112-1', + 'date': '2024-03-15', + 'weekday': '五', + 'period': '3', + 'cell': '曠課', + }; + final record = AbsenceRecord.fromJson(json); + expect(record.academicYear, '112-1'); + expect(record.date, '2024-03-15'); + expect(record.weekday, '五'); + expect(record.period, '3'); + expect(record.status, '曠課'); + expect(record.id, '2024-03-15-五-3-曠課'); + }); + + test('fromJson with alternative keys', () { + final json = { + 'semester': '112-2', + 'date_occurred': '2024-05-01', + 'day': '一', + 'section': '1', + 'attendance_type': '事假', + }; + final record = AbsenceRecord.fromJson(json); + expect(record.academicYear, '112-2'); + expect(record.weekday, '一'); + expect(record.period, '1'); + expect(record.status, '事假'); + }); + }); + + group('AttendanceTotals', () { + test('fromJson parses all fields', () { + final json = {'truancy': 3, 'personalLeave': 5, 'sickLeave': 2, 'officialLeave': 1}; + final totals = AttendanceTotals.fromJson(json); + expect(totals.truancy, 3); + expect(totals.personalLeave, 5); + expect(totals.sickLeave, 2); + expect(totals.officialLeave, 1); + }); + + test('empty factory creates zero values', () { + final totals = AttendanceTotals.empty(); + expect(totals.truancy, 0); + expect(totals.personalLeave, 0); + expect(totals.sickLeave, 0); + expect(totals.officialLeave, 0); + }); + + test('fromJson with alternative keys', () { + final json = {'truant': 2, 'personal_leave': 4, 'sick_leave': 1, 'public_leave': 3}; + final totals = AttendanceTotals.fromJson(json); + expect(totals.truancy, 2); + expect(totals.personalLeave, 4); + expect(totals.sickLeave, 1); + expect(totals.officialLeave, 3); + }); + }); + + group('AttendanceStatistics', () { + test('fromJson parses all fields', () { + final json = { + 'firstSemester': {'曠課': '3', '事假': '2'}, + 'secondSemester': {'曠課': '1'}, + 'total': {'truancy': 4, 'personalLeave': 2, 'sickLeave': 0, 'officialLeave': 0}, + 'statisticsDate': '2024-06-01', + }; + final stats = AttendanceStatistics.fromJson(json); + expect(stats.firstSemester['曠課'], '3'); + expect(stats.secondSemester['曠課'], '1'); + expect(stats.total.truancy, 4); + expect(stats.statisticsDate, '2024-06-01'); + }); + + test('empty factory', () { + final stats = AttendanceStatistics.empty(); + expect(stats.firstSemester, isEmpty); + expect(stats.secondSemester, isEmpty); + expect(stats.statisticsDate, ''); + }); + }); + + group('CourseSchedule', () { + test('fromJson parses fields', () { + final json = {'weekday': '一', 'period': '1'}; + final cs = CourseSchedule.fromJson(json); + expect(cs.weekday, '一'); + expect(cs.period, '1'); + expect(cs.id, '一-1'); + }); + + test('fromJson with alternative keys', () { + final json = {'day': '三', 'section': '5'}; + final cs = CourseSchedule.fromJson(json); + expect(cs.weekday, '三'); + expect(cs.period, '5'); + }); + }); + + group('CourseInfo', () { + test('fromJson parses schedule list', () { + final json = { + 'count': 3, + 'schedule': [ + {'weekday': '一', 'period': '1'}, + {'weekday': '一', 'period': '2'}, + ], + }; + final info = CourseInfo.fromJson(json); + expect(info.count, 3); + expect(info.schedule.length, 2); + expect(info.schedule[0].weekday, '一'); + }); + + test('fromJson handles single schedule as map', () { + final json = { + 'credits': 2, + 'schedule': {'day': '五', 'section': '3'}, + }; + final info = CourseInfo.fromJson(json); + expect(info.count, 2); + expect(info.schedule.length, 1); + expect(info.schedule[0].weekday, '五'); + }); + + test('fromJson handles missing schedule', () { + final json = {'count': 1}; + final info = CourseInfo.fromJson(json); + expect(info.schedule, isEmpty); + }); + }); + + group('SemesterGrade', () { + test('fromJson parses fields', () { + final json = {'attribute': '必修', 'credit': '3', 'score': '85'}; + final grade = SemesterGrade.fromJson(json); + expect(grade.attribute, '必修'); + expect(grade.credit, '3'); + expect(grade.score, '85'); + }); + + test('empty factory', () { + final grade = SemesterGrade.empty(); + expect(grade.attribute, ''); + expect(grade.credit, ''); + expect(grade.score, ''); + }); + }); + + group('SubjectGrade', () { + test('fromJson parses with nested semesters', () { + final json = { + 'subject': '國文', + 'first_semester': {'attribute': '必修', 'credit': '4', 'score': '90'}, + 'second_semester': {'attribute': '必修', 'credit': '4', 'score': '85'}, + 'year_grade': '88', + }; + final grade = SubjectGrade.fromJson(json); + expect(grade.subject, '國文'); + expect(grade.firstSemester.score, '90'); + expect(grade.secondSemester.score, '85'); + expect(grade.yearGrade, '88'); + expect(grade.id, '國文'); + }); + }); + + group('TotalScore', () { + test('fromJson parses fields', () { + final json = { + 'first_semester': '82', + 'second_semester': '78', + 'year': '80', + }; + final score = TotalScore.fromJson(json); + expect(score.firstSemester, '82'); + expect(score.secondSemester, '78'); + expect(score.year, '80'); + }); + }); + + group('DailyPerformance', () { + test('fromJson parses fields', () { + final json = { + 'evaluation': 'Good', + 'description': 'Nice student', + 'serviceHours': '10', + 'specialPerformance': 'Leader', + 'suggestions': 'Keep going', + 'others': 'None', + }; + final dp = DailyPerformance.fromJson(json); + expect(dp.evaluation, 'Good'); + expect(dp.description, 'Nice student'); + expect(dp.serviceHours, '10'); + expect(dp.specialPerformance, 'Leader'); + expect(dp.suggestions, 'Keep going'); + expect(dp.others, 'None'); + }); + + test('isCompletelyEmpty returns true when all empty', () { + final dp = DailyPerformance.empty(); + expect(dp.isCompletelyEmpty, true); + }); + + test('isCompletelyEmpty returns false when any field has value', () { + final dp = DailyPerformance( + evaluation: 'A', + description: '', + serviceHours: '', + specialPerformance: '', + suggestions: '', + others: '', + ); + expect(dp.isCompletelyEmpty, false); + }); + + test('fromJson reads from daily_life_performance nested map', () { + final json = { + 'daily_life_performance': { + 'evaluation': 'Nested eval', + 'description': 'Nested desc', + }, + 'serviceHours': '5', + 'specialPerformance': '', + 'suggestions': '', + 'others': '', + }; + final dp = DailyPerformance.fromJson(json); + expect(dp.evaluation, 'Nested eval'); + expect(dp.description, 'Nested desc'); + expect(dp.serviceHours, '5'); + }); + }); + + group('GradeData', () { + test('fromJson parses complete data', () { + final json = { + 'student_info': 'Class A - Student 1', + 'subjects': [ + { + 'subject': '數學', + 'first_semester': {'attribute': '必修', 'credit': '4', 'score': '92'}, + 'second_semester': {'attribute': '必修', 'credit': '4', 'score': '88'}, + 'year_grade': '90', + }, + ], + 'total_scores': { + '加權': { + 'first_semester': '85', + 'second_semester': '82', + 'year': '84', + }, + }, + 'daily_performance': { + '113-1': { + 'evaluation': 'A', + 'description': '', + 'serviceHours': '10', + 'specialPerformance': '', + 'suggestions': '', + 'others': '', + }, + }, + }; + final data = GradeData.fromJson(json); + expect(data.studentInfo, 'Class A - Student 1'); + expect(data.subjects.length, 1); + expect(data.subjects[0].subject, '數學'); + expect(data.totalScores['加權']!.firstSemester, '85'); + expect(data.dailyPerformance['113-1']!.evaluation, 'A'); + }); + + test('empty factory', () { + final data = GradeData.empty(); + expect(data.studentInfo, ''); + expect(data.subjects, isEmpty); + expect(data.totalScores, isEmpty); + expect(data.dailyPerformance, isEmpty); + }); + }); + + group('ExamMenuItem', () { + test('fromJson parses fields', () { + final json = {'name': 'Midterm', 'url': '/exam/1'}; + final item = ExamMenuItem.fromJson(json, fullUrl: 'https://school.tw/exam/1'); + expect(item.name, 'Midterm'); + expect(item.url, '/exam/1'); + expect(item.fullUrl, 'https://school.tw/exam/1'); + expect(item.id, 'Midterm-/exam/1'); + }); + }); + + group('ExamSubjectScore', () { + test('fromJson parses fields', () { + final json = {'subject': '英文', 'personalScore': '90', 'classAverage': '75'}; + final score = ExamSubjectScore.fromJson(json); + expect(score.subject, '英文'); + expect(score.personalScore, '90'); + expect(score.classAverage, '75'); + expect(score.id, '英文'); + }); + + test('fromJson with alternative keys', () { + final json = {'name': '國文', 'score': '88', 'class_avg': '70'}; + final score = ExamSubjectScore.fromJson(json); + expect(score.subject, '國文'); + expect(score.personalScore, '88'); + expect(score.classAverage, '70'); + }); + }); + + group('ExamSummary', () { + test('fromJson parses all fields', () { + final json = { + 'totalScore': '450', + 'averageScore': '90', + 'classRank': '5', + 'departmentRank': '10', + }; + final summary = ExamSummary.fromJson(json); + expect(summary.totalScore, '450'); + expect(summary.averageScore, '90'); + expect(summary.classRank, '5'); + expect(summary.departmentRank, '10'); + }); + + test('empty factory', () { + final summary = ExamSummary.empty(); + expect(summary.totalScore, ''); + expect(summary.classRank, ''); + }); + }); + + group('StudentInfo', () { + test('fromJson parses all fields', () { + final json = {'studentId': 'S001', 'name': 'Alice', 'className': 'ClassA'}; + final info = StudentInfo.fromJson(json); + expect(info.studentId, 'S001'); + expect(info.name, 'Alice'); + expect(info.className, 'ClassA'); + }); + + test('fromJson with alternative keys', () { + final json = {'student_no': 'S002', 'full_name': 'Bob', 'homeroom': 'ClassB'}; + final info = StudentInfo.fromJson(json); + expect(info.studentId, 'S002'); + expect(info.name, 'Bob'); + expect(info.className, 'ClassB'); + }); + + test('empty factory', () { + final info = StudentInfo.empty(); + expect(info.studentId, ''); + expect(info.name, ''); + expect(info.className, ''); + }); + }); + + group('TimetableEntry', () { + test('fromJson and toJson roundtrip', () { + final json = {'weekday': '二', 'period': '4', 'subject': '物理'}; + final entry = TimetableEntry.fromJson(json); + expect(entry.weekday, '二'); + expect(entry.period, '4'); + expect(entry.subject, '物理'); + expect(entry.id, '二-4-物理'); + final output = entry.toJson(); + expect(output['weekday'], '二'); + expect(output['period'], '4'); + expect(output['subject'], '物理'); + }); + }); + + group('TimetableData', () { + test('fromJson parses entries, periodTimes, and curriculum', () { + final json = { + 'entries': [ + {'weekday': '一', 'period': '1', 'subject': '國文'}, + {'weekday': '一', 'period': '2', 'subject': '數學'}, + ], + 'periodTimes': { + '1': {'startTime': '08:10', 'endTime': '09:00'}, + '2': {'startTime': '09:10', 'endTime': '10:00'}, + }, + 'curriculum': { + '國文': { + 'count': 4, + 'schedule': [ + {'weekday': '一', 'period': '1'}, + ], + }, + }, + }; + final data = TimetableData.fromJson(json); + expect(data.entries.length, 2); + expect(data.entries[0].subject, '國文'); + expect(data.periodTimes['1']!.startTime, '08:10'); + expect(data.curriculum['國文']!.count, 4); + }); + + test('toJson and fromJsonString roundtrip', () { + final data = TimetableData( + entries: [ + TimetableEntry(weekday: '一', period: '1', subject: '英文'), + ], + periodTimes: { + '1': PeriodTime(startTime: '08:00', endTime: '08:50'), + }, + curriculum: { + '英文': CourseInfo( + count: 3, + schedule: [CourseSchedule(weekday: '一', period: '1')], + ), + }, + ); + final jsonStr = data.toJsonString(); + final restored = TimetableData.fromJsonString(jsonStr); + expect(restored, isNotNull); + expect(restored!.entries.length, 1); + expect(restored.entries[0].subject, '英文'); + expect(restored.periodTimes['1']!.startTime, '08:00'); + }); + + test('fromJsonString returns null for invalid input', () { + expect(TimetableData.fromJsonString('not json'), isNull); + }); + + test('fromJson with alternative keys', () { + final json = { + 'timetable': [ + {'day': '五', 'section': '3', 'course': '化學'}, + ], + 'periods': { + '3': {'start_time': '10:10', 'end_time': '11:00'}, + }, + 'classes': {}, + }; + final data = TimetableData.fromJson(json); + expect(data.entries.length, 1); + expect(data.entries[0].subject, '化學'); + }); + }); + + group('SubjectAbsence', () { + test('fromJson parses all fields with computed defaults', () { + final json = { + 'subject': '數學', + 'truancy': 2, + 'personalLeave': 3, + 'totalClasses': 36, + }; + final sa = SubjectAbsence.fromJson(json); + expect(sa.subject, '數學'); + expect(sa.truancy, 2); + expect(sa.personalLeave, 3); + expect(sa.total, 5); + expect(sa.totalClasses, 36); + expect(sa.percentage, 14); + }); + + test('fromJson with explicit total', () { + final json = { + 'subject': '英文', + 'truancy': 1, + 'personalLeave': 1, + 'total': 10, + 'totalClasses': 36, + 'percentage': 28, + }; + final sa = SubjectAbsence.fromJson(json); + expect(sa.total, 10); + expect(sa.percentage, 28); + }); + }); + + group('NoticeItem', () { + test('fromJson parses all fields', () { + final json = { + 'link': 'https://school.tw/notice/1', + 'title': 'Holiday', + 'publisher': 'Admin', + 'date': '2024-06-01', + 'views': '100', + }; + final item = NoticeItem.fromJson(json); + expect(item.link, 'https://school.tw/notice/1'); + expect(item.title, 'Holiday'); + expect(item.publisher, 'Admin'); + expect(item.date, '2024-06-01'); + expect(item.views, '100'); + }); + + test('fromJson with alternative keys', () { + final json = { + 'href': '/n/2', + 'subject': 'Exam', + 'department': 'Academic', + 'publish_date': '2024-07-01', + 'hits': 50, + }; + final item = NoticeItem.fromJson(json); + expect(item.link, '/n/2'); + expect(item.title, 'Exam'); + expect(item.publisher, 'Academic'); + expect(item.date, '2024-07-01'); + }); + }); + + group('VocPassUser', () { + test('fromJson parses all fields', () { + final json = { + 'id': 'u1', + 'name': 'Alice', + 'username': 'alice123', + 'email': 'alice@example.com', + 'avatar': 'https://img/avatar.png', + 'email_visibility': true, + 'verified': true, + 'share_status': true, + 'school': '鶯歌工商', + }; + final user = VocPassUser.fromJson(json); + expect(user.id, 'u1'); + expect(user.name, 'Alice'); + expect(user.username, 'alice123'); + expect(user.email, 'alice@example.com'); + expect(user.avatarURL, 'https://img/avatar.png'); + expect(user.emailVisibility, true); + expect(user.verified, true); + expect(user.shareStatus, true); + expect(user.verifiedSchool, '鶯歌工商'); + expect(user.displayName, 'Alice'); + }); + + test('displayName falls back to username when name is empty', () { + final json = { + 'id': 'u2', + 'name': '', + 'username': 'bob456', + 'email': '', + }; + final user = VocPassUser.fromJson(json); + expect(user.displayName, 'bob456'); + }); + + test('avatarURL returns null for empty avatar', () { + final json = { + 'id': 'u3', + 'name': '', + 'username': 'carol', + 'email': '', + 'avatar': '', + }; + final user = VocPassUser.fromJson(json); + expect(user.avatarURL, isNull); + }); + + test('verifiedSchool returns null for blank school', () { + final json = { + 'id': 'u4', + 'name': '', + 'username': 'dave', + 'email': '', + 'school': ' ', + }; + final user = VocPassUser.fromJson(json); + expect(user.verifiedSchool, isNull); + }); + + test('shareStatus is null when key not present', () { + final json = { + 'id': 'u5', + 'name': '', + 'username': 'eve', + 'email': '', + }; + final user = VocPassUser.fromJson(json); + expect(user.shareStatus, isNull); + }); + }); + + group('VocPassPublicUser', () { + test('fromJson parses fields', () { + final json = { + 'id': 'pu1', + 'name': 'Public Alice', + 'username': 'palice', + 'avatar': 'https://img/a.png', + }; + final user = VocPassPublicUser.fromJson(json); + expect(user.id, 'pu1'); + expect(user.displayName, 'Public Alice'); + expect(user.avatarURL, 'https://img/a.png'); + }); + + test('displayName falls back to username', () { + final json = {'id': 'pu2', 'name': '', 'username': 'pub_user'}; + final user = VocPassPublicUser.fromJson(json); + expect(user.displayName, 'pub_user'); + }); + }); + + group('Restaurant', () { + test('fromJson parses all fields including map', () { + final json = { + 'id': 'r1', + 'name': 'Cafe A', + 'school': '鶯歌工商', + 'icon': 'https://img/cafe.png', + 'map': {'lon': 121.35, 'lat': 24.95}, + 'user': 'u1', + 'address': '100 Main St', + }; + final r = Restaurant.fromJson(json); + expect(r.id, 'r1'); + expect(r.name, 'Cafe A'); + expect(r.iconURL, 'https://img/cafe.png'); + expect(r.map!.lon, 121.35); + expect(r.map!.lat, 24.95); + expect(r.address, '100 Main St'); + }); + + test('iconURL returns null for empty icon', () { + final json = {'id': 'r2', 'name': 'B', 'school': 's', 'icon': ''}; + final r = Restaurant.fromJson(json); + expect(r.iconURL, isNull); + }); + }); + + group('RestaurantEvaluation', () { + test('fromJson and plainDescription strip HTML', () { + final json = { + 'id': 'e1', + 'title': 'Great food', + 'description': '

Really good!

', + 'score': 5, + 'user': 'u1', + }; + final eval = RestaurantEvaluation.fromJson(json); + expect(eval.title, 'Great food'); + expect(eval.score, 5); + expect(eval.plainDescription, 'Really good!'); + }); + }); + + group('RestaurantMenu', () { + test('fromJson parses fields', () { + final json = { + 'id': 'm1', + 'menu': 'https://img/menu.png', + 'restaurant': 'r1', + 'user': 'u1', + }; + final menu = RestaurantMenu.fromJson(json); + expect(menu.id, 'm1'); + expect(menu.menuURL, 'https://img/menu.png'); + expect(menu.restaurant, 'r1'); + }); + + test('menuURL returns null for empty menu', () { + final json = {'id': 'm2', 'menu': '', 'restaurant': 'r2', 'user': 'u2'}; + final menu = RestaurantMenu.fromJson(json); + expect(menu.menuURL, isNull); + }); + }); + + group('PeriodTime', () { + test('fromMap and toJson roundtrip', () { + final json = {'startTime': '08:00', 'endTime': '08:50'}; + final pt = PeriodTime.fromMap(json); + expect(pt.startTime, '08:00'); + expect(pt.endTime, '08:50'); + final output = pt.toJson(); + expect(output['startTime'], '08:00'); + expect(output['endTime'], '08:50'); + }); + }); + + group('CourseExtra', () { + test('fromJson and toJson roundtrip', () { + final json = {'room': 'A101', 'teacher': 'Mr. Smith'}; + final ce = CourseExtra.fromJson(json); + expect(ce.room, 'A101'); + expect(ce.teacher, 'Mr. Smith'); + final output = ce.toJson(); + expect(output['room'], 'A101'); + expect(output['teacher'], 'Mr. Smith'); + }); + + test('default constructor has empty strings', () { + final ce = CourseExtra(); + expect(ce.room, ''); + expect(ce.teacher, ''); + }); + }); +} diff --git a/test/school_config_test.dart b/test/school_config_test.dart new file mode 100644 index 0000000..d087310 --- /dev/null +++ b/test/school_config_test.dart @@ -0,0 +1,368 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:VosPass/config/school_config.dart'; + +void main() { + group('FieldConfig', () { + test('fromJson parses name', () { + final fc = FieldConfig.fromJson({'name': 'LoginName'}); + expect(fc.name, 'LoginName'); + }); + + test('fromJson defaults to empty string', () { + final fc = FieldConfig.fromJson({}); + expect(fc.name, ''); + }); + + test('toJson roundtrip', () { + final fc = FieldConfig.fromJson({'name': 'X'}); + expect(fc.toJson(), {'name': 'X'}); + }); + }); + + group('ButtonConfig', () { + test('fromJson reads class key', () { + final bc = ButtonConfig.fromJson({'class': 'loginBtnAdjust'}); + expect(bc.cssClass, 'loginBtnAdjust'); + }); + + test('toJson roundtrip', () { + const bc = ButtonConfig(cssClass: 'btn'); + expect(bc.toJson(), {'class': 'btn'}); + }); + }); + + group('UrlConfig', () { + test('fromJson parses all fields', () { + final uc = UrlConfig.fromJson({ + 'login': '/auth/Online', + 'logined': '/online/student/frames.asp', + 'root': '/', + }); + expect(uc.login, '/auth/Online'); + expect(uc.logined, '/online/student/frames.asp'); + expect(uc.root, '/'); + }); + + test('fromJson defaults to empty strings', () { + final uc = UrlConfig.fromJson({}); + expect(uc.login, ''); + expect(uc.logined, ''); + expect(uc.root, ''); + }); + + test('toJson roundtrip', () { + const uc = UrlConfig(login: '/a', logined: '/b', root: '/c'); + final json = uc.toJson(); + final restored = UrlConfig.fromJson(json); + expect(restored.login, '/a'); + expect(restored.logined, '/b'); + expect(restored.root, '/c'); + }); + }); + + group('RouteConfig', () { + test('fromJson parses exam_results', () { + final rc = RouteConfig.fromJson({'exam_results': '/exam'}); + expect(rc.examResults, '/exam'); + }); + + test('fromJson reads examResults alternative key', () { + final rc = RouteConfig.fromJson({'examResults': '/exam2'}); + expect(rc.examResults, '/exam2'); + }); + + test('fromJson returns null when not provided', () { + final rc = RouteConfig.fromJson({}); + expect(rc.examResults, isNull); + }); + + test('toJson roundtrip', () { + const rc = RouteConfig(examResults: '/e'); + expect(rc.toJson(), {'exam_results': '/e'}); + }); + }); + + group('NoticeConfig', () { + test('fromJson parses fields', () { + final nc = NoticeConfig.fromJson({'vision': 'v1', 'url': 'https://notice.tw'}); + expect(nc.vision, 'v1'); + expect(nc.url, 'https://notice.tw'); + }); + + test('url is null when not provided', () { + final nc = NoticeConfig.fromJson({'vision': 'v2'}); + expect(nc.url, isNull); + }); + }); + + group('LoginConfig', () { + test('fromJson parses all fields including captchaImage', () { + final json = { + 'username': {'name': 'LoginName'}, + 'password': {'name': 'PassString'}, + 'captcha': {'name': 'CaptchaCode'}, + 'captchaImage': {'selector': '.captcha-img', 'type': 'class'}, + 'button': {'class': 'loginBtn'}, + 'successKeywords': ['成功', '歡迎'], + }; + final lc = LoginConfig.fromJson(json); + expect(lc.username.name, 'LoginName'); + expect(lc.password.name, 'PassString'); + expect(lc.captcha.name, 'CaptchaCode'); + expect(lc.captchaImage!.selector, '.captcha-img'); + expect(lc.captchaImage!.type, 'class'); + expect(lc.button.cssClass, 'loginBtn'); + expect(lc.successKeywords, ['成功', '歡迎']); + }); + + test('fromJson parses success keywords from comma-separated string', () { + final json = { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + 'success_keywords': '成功, 歡迎, ok', + }; + final lc = LoginConfig.fromJson(json); + expect(lc.successKeywords, ['成功', '歡迎', 'ok']); + }); + + test('fromJson handles captcha_image alternative key', () { + final json = { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'captcha_image': {'selector': '#captcha', 'type': 'id'}, + 'button': {'class': ''}, + }; + final lc = LoginConfig.fromJson(json); + expect(lc.captchaImage!.selector, '#captcha'); + expect(lc.captchaImage!.type, 'id'); + }); + + test('captchaImage is null when not provided', () { + final json = { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + }; + final lc = LoginConfig.fromJson(json); + expect(lc.captchaImage, isNull); + }); + + test('captchaImage is null when selector and type are both empty', () { + final json = { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'captchaImage': {'selector': '', 'type': ''}, + 'button': {'class': ''}, + }; + final lc = LoginConfig.fromJson(json); + expect(lc.captchaImage, isNull); + }); + + test('successKeywords is null when empty', () { + final json = { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + 'successKeywords': [], + }; + final lc = LoginConfig.fromJson(json); + expect(lc.successKeywords, isNull); + }); + + test('toJson roundtrip', () { + final json = { + 'username': {'name': 'U'}, + 'password': {'name': 'P'}, + 'captcha': {'name': 'C'}, + 'captchaImage': {'selector': 'sel', 'type': 'tp'}, + 'button': {'class': 'btn'}, + 'successKeywords': ['ok'], + }; + final lc = LoginConfig.fromJson(json); + final output = lc.toJson(); + expect(output['username'], {'name': 'U'}); + expect(output['password'], {'name': 'P'}); + expect(output['captcha'], {'name': 'C'}); + expect((output['captchaImage'] as Map)['selector'], 'sel'); + expect(output['successKeywords'], ['ok']); + }); + }); + + group('SchoolConfig', () { + Map fullSchoolJson() => { + 'name': '鶯歌工商', + 'vision': 'v1', + 'app': '1.5.0', + 'beta': false, + 'api': 'https://eschool.ykvs.ntpc.edu.tw', + 'url': { + 'login': '/auth/Online', + 'logined': '/online/student/frames.asp', + 'root': '/', + }, + 'login': { + 'username': {'name': 'LoginName'}, + 'password': {'name': 'PassString'}, + 'captcha': {'name': 'ShCaptchaGenCode'}, + 'captchaImage': {'selector': 'captcha-image', 'type': 'class'}, + 'button': {'class': 'loginBtnAdjust'}, + }, + 'route': { + 'exam_results': '/online/selection_student/exam', + }, + 'notice': {'vision': 'v1', 'url': 'https://notice.tw'}, + 'telephone': '02-12345678', + 'js': 'console.log("hi")', + }; + + test('fromJson parses all fields', () { + final config = SchoolConfig.fromJson(fullSchoolJson()); + expect(config.name, '鶯歌工商'); + expect(config.vision, 'v1'); + expect(config.app, '1.5.0'); + expect(config.beta, false); + expect(config.api, 'https://eschool.ykvs.ntpc.edu.tw'); + expect(config.url.login, '/auth/Online'); + expect(config.login.username.name, 'LoginName'); + expect(config.route.examResults, '/online/selection_student/exam'); + expect(config.notice!.vision, 'v1'); + expect(config.telephone, '02-12345678'); + expect(config.js, 'console.log("hi")'); + }); + + test('fromApi parses with name argument', () { + final json = { + 'vision': 'v2', + 'api': 'https://school.tw', + 'url': {'login': '/', 'logined': '/', 'root': '/'}, + 'login': { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + }, + 'route': {}, + }; + final config = SchoolConfig.fromApi('TestSchool', json); + expect(config.name, 'TestSchool'); + expect(config.vision, 'v2'); + }); + + test('isGuest returns true for guest config', () { + expect(SchoolConfig.guest.isGuest, true); + }); + + test('isGuest returns false for normal config', () { + final config = SchoolConfig.fromJson(fullSchoolJson()); + expect(config.isGuest, false); + }); + + test('loginUrl returns correct Uri', () { + final config = SchoolConfig.fromJson(fullSchoolJson()); + expect(config.loginUrl.toString(), + 'https://eschool.ykvs.ntpc.edu.tw/auth/Online'); + }); + + test('loginUrl returns null for guest', () { + expect(SchoolConfig.guest.loginUrl, isNull); + }); + + test('loginedUrl and rootUrl return correct strings', () { + final config = SchoolConfig.fromJson(fullSchoolJson()); + expect(config.loginedUrl, + 'https://eschool.ykvs.ntpc.edu.tw/online/student/frames.asp'); + expect(config.rootUrl, 'https://eschool.ykvs.ntpc.edu.tw/'); + }); + + test('toJson and fromJson roundtrip', () { + final original = SchoolConfig.fromJson(fullSchoolJson()); + final json = original.toJson(); + final restored = SchoolConfig.fromJson(json); + expect(restored.name, original.name); + expect(restored.api, original.api); + expect(restored.url.login, original.url.login); + expect(restored.login.username.name, original.login.username.name); + }); + + test('toJsonString and fromJsonString roundtrip', () { + final original = SchoolConfig.fromJson(fullSchoolJson()); + final jsonStr = original.toJsonString(); + final restored = SchoolConfig.fromJsonString(jsonStr); + expect(restored, isNotNull); + expect(restored!.name, original.name); + expect(restored.api, original.api); + }); + + test('fromJsonString returns null for invalid input', () { + expect(SchoolConfig.fromJsonString('not json'), isNull); + }); + + test('fromJson handles missing optional fields', () { + final json = { + 'name': 'Test', + 'vision': 'v1', + 'api': 'https://test.tw', + 'url': {'login': '/', 'logined': '/', 'root': '/'}, + 'login': { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + }, + 'route': {}, + }; + final config = SchoolConfig.fromJson(json); + expect(config.notice, isNull); + expect(config.telephone, isNull); + expect(config.js, isNull); + expect(config.app, isNull); + expect(config.beta, false); + }); + + test('_parseAppVersion handles different types', () { + final json1 = { + 'vision': 'v1', + 'api': '', + 'app': '1.0.0', + 'url': {'login': '', 'logined': '', 'root': ''}, + 'login': { + 'username': {'name': ''}, + 'password': {'name': ''}, + 'captcha': {'name': ''}, + 'button': {'class': ''}, + }, + 'route': {}, + }; + expect(SchoolConfig.fromApi('S', json1).app, '1.0.0'); + + final json2 = Map.from(json1); + json2['app'] = 2; + expect(SchoolConfig.fromApi('S', json2).app, '2'); + + final json3 = Map.from(json1); + json3['app'] = null; + expect(SchoolConfig.fromApi('S', json3).app, isNull); + }); + + test('guest config has expected values', () { + final guest = SchoolConfig.guest; + expect(guest.name, '訪客模式'); + expect(guest.api, SchoolConfig.guestApiMarker); + expect(guest.beta, false); + expect(guest.url.login, '/'); + }); + }); + + group('CaptchaImageConfig', () { + test('toJson returns correct map', () { + const ci = CaptchaImageConfig(selector: '.img', type: 'class'); + expect(ci.toJson(), {'selector': '.img', 'type': 'class'}); + }); + }); +} diff --git a/test/w2m_models_test.dart b/test/w2m_models_test.dart new file mode 100644 index 0000000..7ab192f --- /dev/null +++ b/test/w2m_models_test.dart @@ -0,0 +1,283 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:VosPass/models/w2m_models.dart'; + +void main() { + group('W2MUserInfo', () { + test('fromJson parses all fields', () { + final json = {'id': 'u1', 'name': 'Alice', 'avatar': 'https://img/a.png'}; + final user = W2MUserInfo.fromJson(json); + expect(user.id, 'u1'); + expect(user.name, 'Alice'); + expect(user.avatarURL, 'https://img/a.png'); + expect(user.displayName, 'Alice'); + }); + + test('displayName falls back to id when name is empty', () { + final json = {'id': 'u2', 'name': ''}; + final user = W2MUserInfo.fromJson(json); + expect(user.displayName, 'u2'); + }); + + test('avatarURL returns null when avatar is null', () { + final json = {'id': 'u3', 'name': 'Bob'}; + final user = W2MUserInfo.fromJson(json); + expect(user.avatarURL, isNull); + }); + + test('avatarURL returns null for empty avatar', () { + final json = {'id': 'u4', 'name': 'Carol', 'avatar': ''}; + final user = W2MUserInfo.fromJson(json); + expect(user.avatarURL, isNull); + }); + }); + + group('W2MEventSummary', () { + test('fromJson parses all fields', () { + final json = { + 'id': 'e1', + 'title': 'Lunch', + 'description': 'Group lunch', + 'dates': ['2024-07-01', '2024-07-02'], + 'creator': {'id': 'u1', 'name': 'Alice'}, + }; + final summary = W2MEventSummary.fromJson(json); + expect(summary.id, 'e1'); + expect(summary.title, 'Lunch'); + expect(summary.description, 'Group lunch'); + expect(summary.dates, ['2024-07-01', '2024-07-02']); + expect(summary.creator.name, 'Alice'); + }); + + test('fromJson handles missing optional fields', () { + final json = { + 'id': 'e2', + 'creator': {'id': 'u2', 'name': ''}, + }; + final summary = W2MEventSummary.fromJson(json); + expect(summary.title, ''); + expect(summary.description, ''); + expect(summary.dates, isEmpty); + }); + }); + + group('W2MEventListResponse', () { + test('fromJson parses created and participated lists', () { + final json = { + 'created': [ + { + 'id': 'e1', + 'title': 'A', + 'description': '', + 'dates': [], + 'creator': {'id': 'u1', 'name': 'Alice'}, + }, + ], + 'participated': [ + { + 'id': 'e2', + 'title': 'B', + 'description': '', + 'dates': [], + 'creator': {'id': 'u2', 'name': 'Bob'}, + }, + ], + }; + final response = W2MEventListResponse.fromJson(json); + expect(response.created.length, 1); + expect(response.created[0].title, 'A'); + expect(response.participated.length, 1); + expect(response.participated[0].title, 'B'); + }); + + test('fromJson handles missing lists', () { + final json = {}; + final response = W2MEventListResponse.fromJson(json); + expect(response.created, isEmpty); + expect(response.participated, isEmpty); + }); + }); + + group('W2MEvent', () { + test('fromJson parses all fields', () { + final json = { + 'id': 'ev1', + 'title': 'Meeting', + 'slots': ['2024-07-01 09:00', '2024-07-01 09:30', '2024-07-02 10:00'], + 'availability': [ + { + 'user': {'id': 'u1', 'name': 'Alice'}, + 'slots': ['2024-07-01 09:00', '2024-07-01 09:30'], + }, + { + 'user': {'id': 'u2', 'name': 'Bob'}, + 'slots': ['2024-07-01 09:00'], + }, + ], + 'creator': {'id': 'u1', 'name': 'Alice'}, + }; + final event = W2MEvent.fromJson(json); + expect(event.id, 'ev1'); + expect(event.title, 'Meeting'); + expect(event.slots.length, 3); + expect(event.availability.length, 2); + expect(event.creator!.name, 'Alice'); + }); + + test('dates extracts unique date prefixes', () { + final event = W2MEvent( + id: 'ev2', + title: '', + slots: ['2024-07-01 09:00', '2024-07-01 09:30', '2024-07-02 10:00'], + availability: [], + ); + expect(event.dates, ['2024-07-01', '2024-07-02']); + }); + + test('slotCount counts users available for a slot', () { + final event = W2MEvent( + id: 'ev3', + title: '', + slots: ['2024-07-01 09:00'], + availability: [ + W2MUserAvailability( + user: W2MUserInfo(id: 'u1', name: 'A'), + slots: ['2024-07-01 09:00'], + ), + W2MUserAvailability( + user: W2MUserInfo(id: 'u2', name: 'B'), + slots: ['2024-07-01 09:00'], + ), + W2MUserAvailability( + user: W2MUserInfo(id: 'u3', name: 'C'), + slots: ['2024-07-01 10:00'], + ), + ], + ); + expect(event.slotCount('2024-07-01 09:00'), 2); + expect(event.slotCount('2024-07-01 10:00'), 1); + expect(event.slotCount('2024-07-01 11:00'), 0); + }); + + test('usersAvailable returns matching users', () { + final event = W2MEvent( + id: 'ev4', + title: '', + slots: ['slot1'], + availability: [ + W2MUserAvailability( + user: W2MUserInfo(id: 'u1', name: 'A'), + slots: ['slot1'], + ), + W2MUserAvailability( + user: W2MUserInfo(id: 'u2', name: 'B'), + slots: ['slot2'], + ), + ], + ); + final available = event.usersAvailable('slot1'); + expect(available.length, 1); + expect(available[0].user.id, 'u1'); + }); + + test('maxCount returns highest slot count or 1 if zero', () { + final emptyEvent = W2MEvent( + id: 'ev5', + title: '', + slots: ['s1'], + availability: [], + ); + expect(emptyEvent.maxCount, 1); + + final event = W2MEvent( + id: 'ev6', + title: '', + slots: ['s1', 's2'], + availability: [ + W2MUserAvailability( + user: W2MUserInfo(id: 'u1', name: 'A'), + slots: ['s1', 's2'], + ), + W2MUserAvailability( + user: W2MUserInfo(id: 'u2', name: 'B'), + slots: ['s1'], + ), + ], + ); + expect(event.maxCount, 2); + }); + + test('fromJson with null creator', () { + final json = { + 'id': 'ev7', + 'title': '', + 'slots': [], + 'availability': [], + }; + final event = W2MEvent.fromJson(json); + expect(event.creator, isNull); + }); + }); + + group('W2MUserAvailability', () { + test('fromJson parses fields', () { + final json = { + 'user': {'id': 'u1', 'name': 'Alice'}, + 'slots': ['s1', 's2'], + }; + final avail = W2MUserAvailability.fromJson(json); + expect(avail.user.name, 'Alice'); + expect(avail.slots, ['s1', 's2']); + }); + }); + + group('W2MSlot', () { + test('label concatenates date and time', () { + const slot = W2MSlot(dateString: '2024-07-01', timeString: '09:00'); + expect(slot.label, '2024-07-01 09:00'); + }); + + test('equality and hashCode', () { + const a = W2MSlot(dateString: '2024-07-01', timeString: '09:00'); + const b = W2MSlot(dateString: '2024-07-01', timeString: '09:00'); + const c = W2MSlot(dateString: '2024-07-01', timeString: '10:00'); + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + expect(a, isNot(equals(c))); + }); + }); + + group('w2mDisplayTimes', () { + test('generates times from 06:00 to 23:30', () { + final times = w2mDisplayTimes(); + expect(times.first, '06:00'); + expect(times.last, '23:30'); + expect(times.length, 36); + expect(times.contains('12:00'), true); + expect(times.contains('12:30'), true); + }); + }); + + group('w2mShortDate', () { + test('formats valid date string', () { + final result = w2mShortDate('2024-07-01'); + expect(result, contains('7/1')); + expect(result, contains('(一)')); + }); + + test('returns original string for invalid date', () { + expect(w2mShortDate('not-a-date'), 'not-a-date'); + }); + + test('formats Sunday correctly', () { + final result = w2mShortDate('2024-06-30'); + expect(result, contains('6/30')); + expect(result, contains('(日)')); + }); + + test('formats Saturday correctly', () { + final result = w2mShortDate('2024-06-29'); + expect(result, contains('6/29')); + expect(result, contains('(六)')); + }); + }); +}