diff --git a/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg b/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg new file mode 100644 index 0000000000..ab80d41cfa --- /dev/null +++ b/apps/flutter_parent/assets/svg/canvas-parent-login-logo-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg b/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg index a6413426ae..b595560d0c 100644 --- a/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg +++ b/apps/flutter_parent/assets/svg/canvas-parent-login-logo.svg @@ -1,10 +1,10 @@ - - - - - - - + + + + + + + diff --git a/apps/flutter_parent/flutter_parent_sdk_url b/apps/flutter_parent/flutter_parent_sdk_url new file mode 100644 index 0000000000..6fcfede9b9 --- /dev/null +++ b/apps/flutter_parent/flutter_parent_sdk_url @@ -0,0 +1 @@ +https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_2.5.3-stable.tar.xz \ No newline at end of file diff --git a/apps/flutter_parent/lib/l10n/app_localizations.dart b/apps/flutter_parent/lib/l10n/app_localizations.dart index 26fa996e0b..4872b5fe70 100644 --- a/apps/flutter_parent/lib/l10n/app_localizations.dart +++ b/apps/flutter_parent/lib/l10n/app_localizations.dart @@ -1705,4 +1705,7 @@ class AppLocalizations { String get aboutVersionTitle => Intl.message('Version', desc: 'Title for Version field on about page'); + + String get aboutLogoSemanticsLabel => + Intl.message('Instructure logo', desc: 'Semantics label for the Instructure logo on the about page'); } diff --git a/apps/flutter_parent/lib/l10n/res/intl_en.arb b/apps/flutter_parent/lib/l10n/res/intl_en.arb index 9d2ca6f103..d5edbf3ece 100644 --- a/apps/flutter_parent/lib/l10n/res/intl_en.arb +++ b/apps/flutter_parent/lib/l10n/res/intl_en.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2023-04-14T11:04:46.988317", + "@@last_modified": "2023-08-25T11:04:20.901151", "alertsLabel": "Alerts", "@alertsLabel": { "description": "The label for the Alerts tab", @@ -2742,5 +2742,12 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "Instructure logo": "Instructure logo", + "@Instructure logo": { + "description": "Semantics label for the Instructure logo on the about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/apps/flutter_parent/lib/l10n/res/intl_messages.arb b/apps/flutter_parent/lib/l10n/res/intl_messages.arb index 9d2ca6f103..d5edbf3ece 100644 --- a/apps/flutter_parent/lib/l10n/res/intl_messages.arb +++ b/apps/flutter_parent/lib/l10n/res/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2023-04-14T11:04:46.988317", + "@@last_modified": "2023-08-25T11:04:20.901151", "alertsLabel": "Alerts", "@alertsLabel": { "description": "The label for the Alerts tab", @@ -2742,5 +2742,12 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "Instructure logo": "Instructure logo", + "@Instructure logo": { + "description": "Semantics label for the Instructure logo on the about page", + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/apps/flutter_parent/lib/models/alert.dart b/apps/flutter_parent/lib/models/alert.dart index 78d3d4caf1..6f3d04ff2d 100644 --- a/apps/flutter_parent/lib/models/alert.dart +++ b/apps/flutter_parent/lib/models/alert.dart @@ -109,6 +109,22 @@ abstract class Alert implements Built { int index2 = htmlUrl.lastIndexOf('/discussion_topics'); return htmlUrl.substring(index1, index2); } + + String getCourseIdForGradeAlerts() { + if (alertType == AlertType.courseGradeLow || alertType == AlertType.courseGradeHigh) { + return contextId; + } else if (alertType == AlertType.assignmentGradeLow || alertType == AlertType.assignmentGradeHigh) { + return _getCourseIdFromUrl(); + } else { + return null; + } + } + + String _getCourseIdFromUrl() { + RegExp regex = RegExp(r'/courses/(\d+)/'); + Match match = regex.firstMatch(htmlUrl); + return (match != null && match.groupCount >= 1) ? match.group(1) : null; + } } /// If you need to change the values sent over the wire when serializing you diff --git a/apps/flutter_parent/lib/models/assignment.dart b/apps/flutter_parent/lib/models/assignment.dart index ce3d4d27dd..30b7f756a7 100644 --- a/apps/flutter_parent/lib/models/assignment.dart +++ b/apps/flutter_parent/lib/models/assignment.dart @@ -185,6 +185,10 @@ abstract class Assignment implements Built { bool get isDiscussion => submissionTypes.contains(SubmissionTypes.discussionTopic); bool get isQuiz => submissionTypes.contains(SubmissionTypes.onlineQuiz); + + bool isGradingTypeQuantitative() { + return gradingType == GradingType.points || gradingType == GradingType.percent; + } } @BuiltValueEnum(wireName: 'grading_type') diff --git a/apps/flutter_parent/lib/models/course.dart b/apps/flutter_parent/lib/models/course.dart index b1bd3fa47a..931a1bc5b0 100644 --- a/apps/flutter_parent/lib/models/course.dart +++ b/apps/flutter_parent/lib/models/course.dart @@ -15,7 +15,10 @@ library course; import 'package:built_collection/built_collection.dart'; import 'package:built_value/built_value.dart'; +import 'package:built_value/json_object.dart'; import 'package:built_value/serializer.dart'; +import 'package:flutter_parent/models/course_settings.dart'; +import 'package:flutter_parent/models/grading_scheme_item.dart'; import 'package:flutter_parent/models/section.dart'; import 'package:flutter_parent/models/term.dart'; @@ -123,6 +126,19 @@ abstract class Course implements Built { @nullable BuiltList
get sections; + @nullable + CourseSettings get settings; + + @nullable + @BuiltValueField(wireName: 'grading_scheme') + BuiltList get gradingScheme; + + List get gradingSchemeItems { + if (gradingScheme == null) return []; + return gradingScheme.map((item) => GradingSchemeItem.fromJson(item)).where((element) => element != null).toList() + ..sort((a, b) => b.value.compareTo(a.value)); + } + static void _initializeBuilder(CourseBuilder b) => b ..id = '' ..enrollments = ListBuilder() @@ -172,6 +188,12 @@ abstract class Course implements Built { bool isValidForCurrentStudent(String currentStudentId) { return enrollments?.any((enrollment) => enrollment.userId == currentStudentId) ?? false; } + + String convertScoreToLetterGrade(double score, double maxScore) { + if (maxScore == 0.0 || gradingSchemeItems.isEmpty) return ""; + double percent = score / maxScore; + return gradingSchemeItems.firstWhere((element) => percent >= element.value, orElse: () => gradingSchemeItems.last).grade; + } } @BuiltValueEnum(wireName: 'default_view') diff --git a/apps/flutter_parent/lib/models/course.g.dart b/apps/flutter_parent/lib/models/course.g.dart index 9efff7de24..737bb7026e 100644 --- a/apps/flutter_parent/lib/models/course.g.dart +++ b/apps/flutter_parent/lib/models/course.g.dart @@ -87,77 +87,81 @@ class _$CourseSerializer implements StructuredSerializer { serializers.serialize(object.restrictEnrollmentsToCourseDates, specifiedType: const FullType(bool)), ]; - result.add('original_name'); - if (object.originalName == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.originalName, - specifiedType: const FullType(String))); - } - result.add('course_code'); - if (object.courseCode == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseCode, - specifiedType: const FullType(String))); - } - result.add('start_at'); - if (object.startAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.startAt, + Object value; + value = object.originalName; + + result + ..add('original_name') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseCode; + + result + ..add('course_code') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.startAt; + + result + ..add('start_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('end_at'); - if (object.endAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.endAt, + value = object.endAt; + + result + ..add('end_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('syllabus_body'); - if (object.syllabusBody == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.syllabusBody, - specifiedType: const FullType(String))); - } - result.add('image_download_url'); - if (object.imageDownloadUrl == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.imageDownloadUrl, - specifiedType: const FullType(String))); - } - result.add('workflow_state'); - if (object.workflowState == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.workflowState, - specifiedType: const FullType(String))); - } - result.add('default_view'); - if (object.homePage == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.homePage, + value = object.syllabusBody; + + result + ..add('syllabus_body') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.imageDownloadUrl; + + result + ..add('image_download_url') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.workflowState; + + result + ..add('workflow_state') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.homePage; + + result + ..add('default_view') + ..add(serializers.serialize(value, specifiedType: const FullType(HomePage))); - } - result.add('term'); - if (object.term == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.term, - specifiedType: const FullType(Term))); - } - result.add('sections'); - if (object.sections == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.sections, + value = object.term; + + result + ..add('term') + ..add(serializers.serialize(value, specifiedType: const FullType(Term))); + value = object.sections; + + result + ..add('sections') + ..add(serializers.serialize(value, specifiedType: const FullType(BuiltList, const [const FullType(Section)]))); - } + value = object.settings; + + result + ..add('settings') + ..add(serializers.serialize(value, + specifiedType: const FullType(CourseSettings))); + value = object.gradingScheme; + + result + ..add('grading_scheme') + ..add(serializers.serialize(value, + specifiedType: + const FullType(BuiltList, const [const FullType(JsonObject)]))); + return result; } @@ -170,8 +174,7 @@ class _$CourseSerializer implements StructuredSerializer { while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; - if (value == null) continue; + final Object value = iterator.current; switch (key) { case 'id': result.id = serializers.deserialize(value, @@ -265,6 +268,16 @@ class _$CourseSerializer implements StructuredSerializer { BuiltList, const [const FullType(Section)])) as BuiltList); break; + case 'settings': + result.settings.replace(serializers.deserialize(value, + specifiedType: const FullType(CourseSettings)) as CourseSettings); + break; + case 'grading_scheme': + result.gradingScheme.replace(serializers.deserialize(value, + specifiedType: const FullType( + BuiltList, const [const FullType(JsonObject)])) + as BuiltList); + break; } } @@ -342,6 +355,10 @@ class _$Course extends Course { final Term term; @override final BuiltList
sections; + @override + final CourseSettings settings; + @override + final BuiltList gradingScheme; factory _$Course([void Function(CourseBuilder) updates]) => (new CourseBuilder()..update(updates)).build(); @@ -372,46 +389,29 @@ class _$Course extends Course { this.workflowState, this.homePage, this.term, - this.sections}) + this.sections, + this.settings, + this.gradingScheme}) : super._() { - if (id == null) { - throw new BuiltValueNullFieldError('Course', 'id'); - } - if (name == null) { - throw new BuiltValueNullFieldError('Course', 'name'); - } - if (hideFinalGrades == null) { - throw new BuiltValueNullFieldError('Course', 'hideFinalGrades'); - } - if (isPublic == null) { - throw new BuiltValueNullFieldError('Course', 'isPublic'); - } - if (enrollments == null) { - throw new BuiltValueNullFieldError('Course', 'enrollments'); - } - if (needsGradingCount == null) { - throw new BuiltValueNullFieldError('Course', 'needsGradingCount'); - } - if (applyAssignmentGroupWeights == null) { - throw new BuiltValueNullFieldError( - 'Course', 'applyAssignmentGroupWeights'); - } - if (isFavorite == null) { - throw new BuiltValueNullFieldError('Course', 'isFavorite'); - } - if (accessRestrictedByDate == null) { - throw new BuiltValueNullFieldError('Course', 'accessRestrictedByDate'); - } - if (hasWeightedGradingPeriods == null) { - throw new BuiltValueNullFieldError('Course', 'hasWeightedGradingPeriods'); - } - if (hasGradingPeriods == null) { - throw new BuiltValueNullFieldError('Course', 'hasGradingPeriods'); - } - if (restrictEnrollmentsToCourseDates == null) { - throw new BuiltValueNullFieldError( - 'Course', 'restrictEnrollmentsToCourseDates'); - } + BuiltValueNullFieldError.checkNotNull(id, 'Course', 'id'); + BuiltValueNullFieldError.checkNotNull(name, 'Course', 'name'); + BuiltValueNullFieldError.checkNotNull( + hideFinalGrades, 'Course', 'hideFinalGrades'); + BuiltValueNullFieldError.checkNotNull(isPublic, 'Course', 'isPublic'); + BuiltValueNullFieldError.checkNotNull(enrollments, 'Course', 'enrollments'); + BuiltValueNullFieldError.checkNotNull( + needsGradingCount, 'Course', 'needsGradingCount'); + BuiltValueNullFieldError.checkNotNull( + applyAssignmentGroupWeights, 'Course', 'applyAssignmentGroupWeights'); + BuiltValueNullFieldError.checkNotNull(isFavorite, 'Course', 'isFavorite'); + BuiltValueNullFieldError.checkNotNull( + accessRestrictedByDate, 'Course', 'accessRestrictedByDate'); + BuiltValueNullFieldError.checkNotNull( + hasWeightedGradingPeriods, 'Course', 'hasWeightedGradingPeriods'); + BuiltValueNullFieldError.checkNotNull( + hasGradingPeriods, 'Course', 'hasGradingPeriods'); + BuiltValueNullFieldError.checkNotNull(restrictEnrollmentsToCourseDates, + 'Course', 'restrictEnrollmentsToCourseDates'); } @override @@ -451,7 +451,9 @@ class _$Course extends Course { workflowState == other.workflowState && homePage == other.homePage && term == other.term && - sections == other.sections; + sections == other.sections && + settings == other.settings && + gradingScheme == other.gradingScheme; } @override @@ -474,26 +476,26 @@ class _$Course extends Course { $jc( $jc( $jc( - $jc($jc($jc($jc($jc($jc($jc($jc(0, currentScore.hashCode), finalScore.hashCode), currentGrade.hashCode), finalGrade.hashCode), id.hashCode), name.hashCode), originalName.hashCode), - courseCode.hashCode), - startAt.hashCode), - endAt.hashCode), - syllabusBody.hashCode), - hideFinalGrades.hashCode), - isPublic.hashCode), - enrollments.hashCode), - needsGradingCount.hashCode), - applyAssignmentGroupWeights.hashCode), - isFavorite.hashCode), - accessRestrictedByDate.hashCode), - imageDownloadUrl.hashCode), - hasWeightedGradingPeriods.hashCode), - hasGradingPeriods.hashCode), - restrictEnrollmentsToCourseDates.hashCode), - workflowState.hashCode), - homePage.hashCode), - term.hashCode), - sections.hashCode)); + $jc($jc($jc($jc($jc($jc($jc($jc($jc($jc(0, currentScore.hashCode), finalScore.hashCode), currentGrade.hashCode), finalGrade.hashCode), id.hashCode), name.hashCode), originalName.hashCode), courseCode.hashCode), startAt.hashCode), + endAt.hashCode), + syllabusBody.hashCode), + hideFinalGrades.hashCode), + isPublic.hashCode), + enrollments.hashCode), + needsGradingCount.hashCode), + applyAssignmentGroupWeights.hashCode), + isFavorite.hashCode), + accessRestrictedByDate.hashCode), + imageDownloadUrl.hashCode), + hasWeightedGradingPeriods.hashCode), + hasGradingPeriods.hashCode), + restrictEnrollmentsToCourseDates.hashCode), + workflowState.hashCode), + homePage.hashCode), + term.hashCode), + sections.hashCode), + settings.hashCode), + gradingScheme.hashCode)); } @override @@ -525,7 +527,9 @@ class _$Course extends Course { ..add('workflowState', workflowState) ..add('homePage', homePage) ..add('term', term) - ..add('sections', sections)) + ..add('sections', sections) + ..add('settings', settings) + ..add('gradingScheme', gradingScheme)) .toString(); } } @@ -651,38 +655,52 @@ class CourseBuilder implements Builder { _$this._sections ??= new ListBuilder
(); set sections(ListBuilder
sections) => _$this._sections = sections; + CourseSettingsBuilder _settings; + CourseSettingsBuilder get settings => + _$this._settings ??= new CourseSettingsBuilder(); + set settings(CourseSettingsBuilder settings) => _$this._settings = settings; + + ListBuilder _gradingScheme; + ListBuilder get gradingScheme => + _$this._gradingScheme ??= new ListBuilder(); + set gradingScheme(ListBuilder gradingScheme) => + _$this._gradingScheme = gradingScheme; + CourseBuilder() { Course._initializeBuilder(this); } CourseBuilder get _$this { - if (_$v != null) { - _currentScore = _$v.currentScore; - _finalScore = _$v.finalScore; - _currentGrade = _$v.currentGrade; - _finalGrade = _$v.finalGrade; - _id = _$v.id; - _name = _$v.name; - _originalName = _$v.originalName; - _courseCode = _$v.courseCode; - _startAt = _$v.startAt; - _endAt = _$v.endAt; - _syllabusBody = _$v.syllabusBody; - _hideFinalGrades = _$v.hideFinalGrades; - _isPublic = _$v.isPublic; - _enrollments = _$v.enrollments?.toBuilder(); - _needsGradingCount = _$v.needsGradingCount; - _applyAssignmentGroupWeights = _$v.applyAssignmentGroupWeights; - _isFavorite = _$v.isFavorite; - _accessRestrictedByDate = _$v.accessRestrictedByDate; - _imageDownloadUrl = _$v.imageDownloadUrl; - _hasWeightedGradingPeriods = _$v.hasWeightedGradingPeriods; - _hasGradingPeriods = _$v.hasGradingPeriods; - _restrictEnrollmentsToCourseDates = _$v.restrictEnrollmentsToCourseDates; - _workflowState = _$v.workflowState; - _homePage = _$v.homePage; - _term = _$v.term?.toBuilder(); - _sections = _$v.sections?.toBuilder(); + final $v = _$v; + if ($v != null) { + _currentScore = $v.currentScore; + _finalScore = $v.finalScore; + _currentGrade = $v.currentGrade; + _finalGrade = $v.finalGrade; + _id = $v.id; + _name = $v.name; + _originalName = $v.originalName; + _courseCode = $v.courseCode; + _startAt = $v.startAt; + _endAt = $v.endAt; + _syllabusBody = $v.syllabusBody; + _hideFinalGrades = $v.hideFinalGrades; + _isPublic = $v.isPublic; + _enrollments = $v.enrollments.toBuilder(); + _needsGradingCount = $v.needsGradingCount; + _applyAssignmentGroupWeights = $v.applyAssignmentGroupWeights; + _isFavorite = $v.isFavorite; + _accessRestrictedByDate = $v.accessRestrictedByDate; + _imageDownloadUrl = $v.imageDownloadUrl; + _hasWeightedGradingPeriods = $v.hasWeightedGradingPeriods; + _hasGradingPeriods = $v.hasGradingPeriods; + _restrictEnrollmentsToCourseDates = $v.restrictEnrollmentsToCourseDates; + _workflowState = $v.workflowState; + _homePage = $v.homePage; + _term = $v.term?.toBuilder(); + _sections = $v.sections?.toBuilder(); + _settings = $v.settings?.toBuilder(); + _gradingScheme = $v.gradingScheme?.toBuilder(); _$v = null; } return this; @@ -690,9 +708,7 @@ class CourseBuilder implements Builder { @override void replace(Course other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$Course; } @@ -711,29 +727,39 @@ class CourseBuilder implements Builder { finalScore: finalScore, currentGrade: currentGrade, finalGrade: finalGrade, - id: id, - name: name, + id: BuiltValueNullFieldError.checkNotNull(id, 'Course', 'id'), + name: + BuiltValueNullFieldError.checkNotNull(name, 'Course', 'name'), originalName: originalName, courseCode: courseCode, startAt: startAt, endAt: endAt, syllabusBody: syllabusBody, - hideFinalGrades: hideFinalGrades, - isPublic: isPublic, + hideFinalGrades: BuiltValueNullFieldError.checkNotNull( + hideFinalGrades, 'Course', 'hideFinalGrades'), + isPublic: BuiltValueNullFieldError.checkNotNull( + isPublic, 'Course', 'isPublic'), enrollments: enrollments.build(), - needsGradingCount: needsGradingCount, - applyAssignmentGroupWeights: applyAssignmentGroupWeights, - isFavorite: isFavorite, - accessRestrictedByDate: accessRestrictedByDate, + needsGradingCount: BuiltValueNullFieldError.checkNotNull( + needsGradingCount, 'Course', 'needsGradingCount'), + applyAssignmentGroupWeights: BuiltValueNullFieldError.checkNotNull( + applyAssignmentGroupWeights, 'Course', 'applyAssignmentGroupWeights'), + isFavorite: BuiltValueNullFieldError.checkNotNull( + isFavorite, 'Course', 'isFavorite'), + accessRestrictedByDate: BuiltValueNullFieldError.checkNotNull( + accessRestrictedByDate, 'Course', 'accessRestrictedByDate'), imageDownloadUrl: imageDownloadUrl, - hasWeightedGradingPeriods: hasWeightedGradingPeriods, - hasGradingPeriods: hasGradingPeriods, - restrictEnrollmentsToCourseDates: - restrictEnrollmentsToCourseDates, + hasWeightedGradingPeriods: BuiltValueNullFieldError.checkNotNull( + hasWeightedGradingPeriods, 'Course', 'hasWeightedGradingPeriods'), + hasGradingPeriods: + BuiltValueNullFieldError.checkNotNull(hasGradingPeriods, 'Course', 'hasGradingPeriods'), + restrictEnrollmentsToCourseDates: BuiltValueNullFieldError.checkNotNull(restrictEnrollmentsToCourseDates, 'Course', 'restrictEnrollmentsToCourseDates'), workflowState: workflowState, homePage: homePage, term: _term?.build(), - sections: _sections?.build()); + sections: _sections?.build(), + settings: _settings?.build(), + gradingScheme: _gradingScheme?.build()); } catch (_) { String _$failedField; try { @@ -744,6 +770,10 @@ class CourseBuilder implements Builder { _term?.build(); _$failedField = 'sections'; _sections?.build(); + _$failedField = 'settings'; + _settings?.build(); + _$failedField = 'gradingScheme'; + _gradingScheme?.build(); } catch (e) { throw new BuiltValueNestedFieldError( 'Course', _$failedField, e.toString()); @@ -755,4 +785,4 @@ class CourseBuilder implements Builder { } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/course_grade.dart b/apps/flutter_parent/lib/models/course_grade.dart index 5497a97b13..438bf11ca5 100644 --- a/apps/flutter_parent/lib/models/course_grade.dart +++ b/apps/flutter_parent/lib/models/course_grade.dart @@ -83,7 +83,7 @@ class CourseGrade { // double _getFinalScore() => // _enrollment.grade?.finalScore ?? _enrollment.computedFinalScore; - String _getCurrentGrade() => _enrollment?.grades?.currentGrade ?? _enrollment?.computedCurrentGrade; + String _getCurrentGrade() => _enrollment?.grades?.currentGrade ?? _enrollment?.computedCurrentGrade ?? _enrollment?.computedCurrentLetterGrade; // String _getFinalGrade() => // _enrollment.grade?.finalGrade ?? _enrollment.computedFinalGrade; diff --git a/apps/flutter_parent/lib/models/course_settings.dart b/apps/flutter_parent/lib/models/course_settings.dart index 49ae9c3625..6764896326 100644 --- a/apps/flutter_parent/lib/models/course_settings.dart +++ b/apps/flutter_parent/lib/models/course_settings.dart @@ -26,6 +26,10 @@ abstract class CourseSettings implements Built serialize(Serializers serializers, CourseSettings object, {FullType specifiedType = FullType.unspecified}) { final result = []; - if (object.courseSummary != null) { + Object value; + value = object.courseSummary; + if (value != null) { result ..add('syllabus_course_summary') - ..add(serializers.serialize(object.courseSummary, - specifiedType: const FullType(bool))); + ..add( + serializers.serialize(value, specifiedType: const FullType(bool))); + } + value = object.restrictQuantitativeData; + if (value != null) { + result + ..add('restrict_quantitative_data') + ..add( + serializers.serialize(value, specifiedType: const FullType(bool))); } return result; } @@ -39,12 +48,16 @@ class _$CourseSettingsSerializer while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; + final Object value = iterator.current; switch (key) { case 'syllabus_course_summary': result.courseSummary = serializers.deserialize(value, specifiedType: const FullType(bool)) as bool; break; + case 'restrict_quantitative_data': + result.restrictQuantitativeData = serializers.deserialize(value, + specifiedType: const FullType(bool)) as bool; + break; } } @@ -55,11 +68,14 @@ class _$CourseSettingsSerializer class _$CourseSettings extends CourseSettings { @override final bool courseSummary; + @override + final bool restrictQuantitativeData; factory _$CourseSettings([void Function(CourseSettingsBuilder) updates]) => (new CourseSettingsBuilder()..update(updates)).build(); - _$CourseSettings._({this.courseSummary}) : super._(); + _$CourseSettings._({this.courseSummary, this.restrictQuantitativeData}) + : super._(); @override CourseSettings rebuild(void Function(CourseSettingsBuilder) updates) => @@ -72,18 +88,22 @@ class _$CourseSettings extends CourseSettings { @override bool operator ==(Object other) { if (identical(other, this)) return true; - return other is CourseSettings && courseSummary == other.courseSummary; + return other is CourseSettings && + courseSummary == other.courseSummary && + restrictQuantitativeData == other.restrictQuantitativeData; } @override int get hashCode { - return $jf($jc(0, courseSummary.hashCode)); + return $jf( + $jc($jc(0, courseSummary.hashCode), restrictQuantitativeData.hashCode)); } @override String toString() { return (newBuiltValueToStringHelper('CourseSettings') - ..add('courseSummary', courseSummary)) + ..add('courseSummary', courseSummary) + ..add('restrictQuantitativeData', restrictQuantitativeData)) .toString(); } } @@ -97,11 +117,18 @@ class CourseSettingsBuilder set courseSummary(bool courseSummary) => _$this._courseSummary = courseSummary; + bool _restrictQuantitativeData; + bool get restrictQuantitativeData => _$this._restrictQuantitativeData; + set restrictQuantitativeData(bool restrictQuantitativeData) => + _$this._restrictQuantitativeData = restrictQuantitativeData; + CourseSettingsBuilder(); CourseSettingsBuilder get _$this { - if (_$v != null) { - _courseSummary = _$v.courseSummary; + final $v = _$v; + if ($v != null) { + _courseSummary = $v.courseSummary; + _restrictQuantitativeData = $v.restrictQuantitativeData; _$v = null; } return this; @@ -109,9 +136,7 @@ class CourseSettingsBuilder @override void replace(CourseSettings other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$CourseSettings; } @@ -122,11 +147,13 @@ class CourseSettingsBuilder @override _$CourseSettings build() { - final _$result = - _$v ?? new _$CourseSettings._(courseSummary: courseSummary); + final _$result = _$v ?? + new _$CourseSettings._( + courseSummary: courseSummary, + restrictQuantitativeData: restrictQuantitativeData); replace(_$result); return _$result; } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/enrollment.dart b/apps/flutter_parent/lib/models/enrollment.dart index 4d1c904b0e..ed0cfc91b7 100644 --- a/apps/flutter_parent/lib/models/enrollment.dart +++ b/apps/flutter_parent/lib/models/enrollment.dart @@ -77,6 +77,10 @@ abstract class Enrollment implements Built { @BuiltValueField(wireName: 'computed_final_grade') String get computedFinalGrade; + @nullable + @BuiltValueField(wireName: 'computed_current_letter_grade') + String get computedCurrentLetterGrade; + @BuiltValueField(wireName: 'multiple_grading_periods_enabled') bool get multipleGradingPeriodsEnabled; diff --git a/apps/flutter_parent/lib/models/enrollment.g.dart b/apps/flutter_parent/lib/models/enrollment.g.dart index 695fde2b46..00202c3e20 100644 --- a/apps/flutter_parent/lib/models/enrollment.g.dart +++ b/apps/flutter_parent/lib/models/enrollment.g.dart @@ -39,132 +39,119 @@ class _$EnrollmentSerializer implements StructuredSerializer { serializers.serialize(object.limitPrivilegesToCourseSection, specifiedType: const FullType(bool)), ]; - result.add('role'); - if (object.role == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.role, - specifiedType: const FullType(String))); - } - result.add('type'); - if (object.type == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.type, - specifiedType: const FullType(String))); - } - result.add('course_id'); - if (object.courseId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseId, - specifiedType: const FullType(String))); - } - result.add('course_section_id'); - if (object.courseSectionId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.courseSectionId, - specifiedType: const FullType(String))); - } - result.add('grades'); - if (object.grades == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.grades, - specifiedType: const FullType(Grade))); - } - result.add('computed_current_score'); - if (object.computedCurrentScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedCurrentScore, - specifiedType: const FullType(double))); - } - result.add('computed_final_score'); - if (object.computedFinalScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedFinalScore, - specifiedType: const FullType(double))); - } - result.add('computed_current_grade'); - if (object.computedCurrentGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedCurrentGrade, - specifiedType: const FullType(String))); - } - result.add('computed_final_grade'); - if (object.computedFinalGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.computedFinalGrade, - specifiedType: const FullType(String))); - } - result.add('current_period_computed_current_score'); - if (object.currentPeriodComputedCurrentScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedCurrentScore, - specifiedType: const FullType(double))); - } - result.add('current_period_computed_final_score'); - if (object.currentPeriodComputedFinalScore == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedFinalScore, - specifiedType: const FullType(double))); - } - result.add('current_period_computed_current_grade'); - if (object.currentPeriodComputedCurrentGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedCurrentGrade, - specifiedType: const FullType(String))); - } - result.add('current_period_computed_final_grade'); - if (object.currentPeriodComputedFinalGrade == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentPeriodComputedFinalGrade, - specifiedType: const FullType(String))); - } - result.add('current_grading_period_id'); - if (object.currentGradingPeriodId == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentGradingPeriodId, - specifiedType: const FullType(String))); - } - result.add('current_grading_period_title'); - if (object.currentGradingPeriodTitle == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.currentGradingPeriodTitle, - specifiedType: const FullType(String))); - } - result.add('last_activity_at'); - if (object.lastActivityAt == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.lastActivityAt, + Object value; + value = object.role; + + result + ..add('role') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.type; + + result + ..add('type') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseId; + + result + ..add('course_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.courseSectionId; + + result + ..add('course_section_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.grades; + + result + ..add('grades') + ..add(serializers.serialize(value, specifiedType: const FullType(Grade))); + value = object.computedCurrentScore; + + result + ..add('computed_current_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.computedFinalScore; + + result + ..add('computed_final_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.computedCurrentGrade; + + result + ..add('computed_current_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.computedFinalGrade; + + result + ..add('computed_final_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.computedCurrentLetterGrade; + + result + ..add('computed_current_letter_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentPeriodComputedCurrentScore; + + result + ..add('current_period_computed_current_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.currentPeriodComputedFinalScore; + + result + ..add('current_period_computed_final_score') + ..add( + serializers.serialize(value, specifiedType: const FullType(double))); + value = object.currentPeriodComputedCurrentGrade; + + result + ..add('current_period_computed_current_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentPeriodComputedFinalGrade; + + result + ..add('current_period_computed_final_grade') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentGradingPeriodId; + + result + ..add('current_grading_period_id') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.currentGradingPeriodTitle; + + result + ..add('current_grading_period_title') + ..add( + serializers.serialize(value, specifiedType: const FullType(String))); + value = object.lastActivityAt; + + result + ..add('last_activity_at') + ..add(serializers.serialize(value, specifiedType: const FullType(DateTime))); - } - result.add('observed_user'); - if (object.observedUser == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.observedUser, - specifiedType: const FullType(User))); - } - result.add('user'); - if (object.user == null) { - result.add(null); - } else { - result.add(serializers.serialize(object.user, - specifiedType: const FullType(User))); - } + value = object.observedUser; + + result + ..add('observed_user') + ..add(serializers.serialize(value, specifiedType: const FullType(User))); + value = object.user; + + result + ..add('user') + ..add(serializers.serialize(value, specifiedType: const FullType(User))); + return result; } @@ -177,8 +164,7 @@ class _$EnrollmentSerializer implements StructuredSerializer { while (iterator.moveNext()) { final key = iterator.current as String; iterator.moveNext(); - final dynamic value = iterator.current; - if (value == null) continue; + final Object value = iterator.current; switch (key) { case 'role': result.role = serializers.deserialize(value, @@ -228,6 +214,10 @@ class _$EnrollmentSerializer implements StructuredSerializer { result.computedFinalGrade = serializers.deserialize(value, specifiedType: const FullType(String)) as String; break; + case 'computed_current_letter_grade': + result.computedCurrentLetterGrade = serializers.deserialize(value, + specifiedType: const FullType(String)) as String; + break; case 'multiple_grading_periods_enabled': result.multipleGradingPeriodsEnabled = serializers.deserialize(value, specifiedType: const FullType(bool)) as bool; @@ -317,6 +307,8 @@ class _$Enrollment extends Enrollment { @override final String computedFinalGrade; @override + final String computedCurrentLetterGrade; + @override final bool multipleGradingPeriodsEnabled; @override final bool totalsForAllGradingPeriodsOption; @@ -359,6 +351,7 @@ class _$Enrollment extends Enrollment { this.computedFinalScore, this.computedCurrentGrade, this.computedFinalGrade, + this.computedCurrentLetterGrade, this.multipleGradingPeriodsEnabled, this.totalsForAllGradingPeriodsOption, this.currentPeriodComputedCurrentScore, @@ -373,30 +366,18 @@ class _$Enrollment extends Enrollment { this.observedUser, this.user}) : super._() { - if (id == null) { - throw new BuiltValueNullFieldError('Enrollment', 'id'); - } - if (enrollmentState == null) { - throw new BuiltValueNullFieldError('Enrollment', 'enrollmentState'); - } - if (userId == null) { - throw new BuiltValueNullFieldError('Enrollment', 'userId'); - } - if (multipleGradingPeriodsEnabled == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'multipleGradingPeriodsEnabled'); - } - if (totalsForAllGradingPeriodsOption == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'totalsForAllGradingPeriodsOption'); - } - if (associatedUserId == null) { - throw new BuiltValueNullFieldError('Enrollment', 'associatedUserId'); - } - if (limitPrivilegesToCourseSection == null) { - throw new BuiltValueNullFieldError( - 'Enrollment', 'limitPrivilegesToCourseSection'); - } + BuiltValueNullFieldError.checkNotNull(id, 'Enrollment', 'id'); + BuiltValueNullFieldError.checkNotNull( + enrollmentState, 'Enrollment', 'enrollmentState'); + BuiltValueNullFieldError.checkNotNull(userId, 'Enrollment', 'userId'); + BuiltValueNullFieldError.checkNotNull(multipleGradingPeriodsEnabled, + 'Enrollment', 'multipleGradingPeriodsEnabled'); + BuiltValueNullFieldError.checkNotNull(totalsForAllGradingPeriodsOption, + 'Enrollment', 'totalsForAllGradingPeriodsOption'); + BuiltValueNullFieldError.checkNotNull( + associatedUserId, 'Enrollment', 'associatedUserId'); + BuiltValueNullFieldError.checkNotNull(limitPrivilegesToCourseSection, + 'Enrollment', 'limitPrivilegesToCourseSection'); } @override @@ -422,6 +403,7 @@ class _$Enrollment extends Enrollment { computedFinalScore == other.computedFinalScore && computedCurrentGrade == other.computedCurrentGrade && computedFinalGrade == other.computedFinalGrade && + computedCurrentLetterGrade == other.computedCurrentLetterGrade && multipleGradingPeriodsEnabled == other.multipleGradingPeriodsEnabled && totalsForAllGradingPeriodsOption == other.totalsForAllGradingPeriodsOption && @@ -463,13 +445,13 @@ class _$Enrollment extends Enrollment { $jc( $jc( $jc( - $jc($jc($jc($jc($jc($jc($jc(0, role.hashCode), type.hashCode), id.hashCode), courseId.hashCode), courseSectionId.hashCode), enrollmentState.hashCode), - userId.hashCode), - grades.hashCode), - computedCurrentScore.hashCode), - computedFinalScore.hashCode), - computedCurrentGrade.hashCode), - computedFinalGrade.hashCode), + $jc($jc($jc($jc($jc($jc($jc($jc(0, role.hashCode), type.hashCode), id.hashCode), courseId.hashCode), courseSectionId.hashCode), enrollmentState.hashCode), userId.hashCode), + grades.hashCode), + computedCurrentScore.hashCode), + computedFinalScore.hashCode), + computedCurrentGrade.hashCode), + computedFinalGrade.hashCode), + computedCurrentLetterGrade.hashCode), multipleGradingPeriodsEnabled.hashCode), totalsForAllGradingPeriodsOption.hashCode), currentPeriodComputedCurrentScore.hashCode), @@ -500,6 +482,7 @@ class _$Enrollment extends Enrollment { ..add('computedFinalScore', computedFinalScore) ..add('computedCurrentGrade', computedCurrentGrade) ..add('computedFinalGrade', computedFinalGrade) + ..add('computedCurrentLetterGrade', computedCurrentLetterGrade) ..add('multipleGradingPeriodsEnabled', multipleGradingPeriodsEnabled) ..add('totalsForAllGradingPeriodsOption', totalsForAllGradingPeriodsOption) @@ -580,6 +563,11 @@ class EnrollmentBuilder implements Builder { set computedFinalGrade(String computedFinalGrade) => _$this._computedFinalGrade = computedFinalGrade; + String _computedCurrentLetterGrade; + String get computedCurrentLetterGrade => _$this._computedCurrentLetterGrade; + set computedCurrentLetterGrade(String computedCurrentLetterGrade) => + _$this._computedCurrentLetterGrade = computedCurrentLetterGrade; + bool _multipleGradingPeriodsEnabled; bool get multipleGradingPeriodsEnabled => _$this._multipleGradingPeriodsEnabled; @@ -661,34 +649,34 @@ class EnrollmentBuilder implements Builder { } EnrollmentBuilder get _$this { - if (_$v != null) { - _role = _$v.role; - _type = _$v.type; - _id = _$v.id; - _courseId = _$v.courseId; - _courseSectionId = _$v.courseSectionId; - _enrollmentState = _$v.enrollmentState; - _userId = _$v.userId; - _grades = _$v.grades?.toBuilder(); - _computedCurrentScore = _$v.computedCurrentScore; - _computedFinalScore = _$v.computedFinalScore; - _computedCurrentGrade = _$v.computedCurrentGrade; - _computedFinalGrade = _$v.computedFinalGrade; - _multipleGradingPeriodsEnabled = _$v.multipleGradingPeriodsEnabled; - _totalsForAllGradingPeriodsOption = _$v.totalsForAllGradingPeriodsOption; - _currentPeriodComputedCurrentScore = - _$v.currentPeriodComputedCurrentScore; - _currentPeriodComputedFinalScore = _$v.currentPeriodComputedFinalScore; - _currentPeriodComputedCurrentGrade = - _$v.currentPeriodComputedCurrentGrade; - _currentPeriodComputedFinalGrade = _$v.currentPeriodComputedFinalGrade; - _currentGradingPeriodId = _$v.currentGradingPeriodId; - _currentGradingPeriodTitle = _$v.currentGradingPeriodTitle; - _associatedUserId = _$v.associatedUserId; - _lastActivityAt = _$v.lastActivityAt; - _limitPrivilegesToCourseSection = _$v.limitPrivilegesToCourseSection; - _observedUser = _$v.observedUser?.toBuilder(); - _user = _$v.user?.toBuilder(); + final $v = _$v; + if ($v != null) { + _role = $v.role; + _type = $v.type; + _id = $v.id; + _courseId = $v.courseId; + _courseSectionId = $v.courseSectionId; + _enrollmentState = $v.enrollmentState; + _userId = $v.userId; + _grades = $v.grades?.toBuilder(); + _computedCurrentScore = $v.computedCurrentScore; + _computedFinalScore = $v.computedFinalScore; + _computedCurrentGrade = $v.computedCurrentGrade; + _computedFinalGrade = $v.computedFinalGrade; + _computedCurrentLetterGrade = $v.computedCurrentLetterGrade; + _multipleGradingPeriodsEnabled = $v.multipleGradingPeriodsEnabled; + _totalsForAllGradingPeriodsOption = $v.totalsForAllGradingPeriodsOption; + _currentPeriodComputedCurrentScore = $v.currentPeriodComputedCurrentScore; + _currentPeriodComputedFinalScore = $v.currentPeriodComputedFinalScore; + _currentPeriodComputedCurrentGrade = $v.currentPeriodComputedCurrentGrade; + _currentPeriodComputedFinalGrade = $v.currentPeriodComputedFinalGrade; + _currentGradingPeriodId = $v.currentGradingPeriodId; + _currentGradingPeriodTitle = $v.currentGradingPeriodTitle; + _associatedUserId = $v.associatedUserId; + _lastActivityAt = $v.lastActivityAt; + _limitPrivilegesToCourseSection = $v.limitPrivilegesToCourseSection; + _observedUser = $v.observedUser?.toBuilder(); + _user = $v.user?.toBuilder(); _$v = null; } return this; @@ -696,9 +684,7 @@ class EnrollmentBuilder implements Builder { @override void replace(Enrollment other) { - if (other == null) { - throw new ArgumentError.notNull('other'); - } + ArgumentError.checkNotNull(other, 'other'); _$v = other as _$Enrollment; } @@ -715,19 +701,25 @@ class EnrollmentBuilder implements Builder { new _$Enrollment._( role: role, type: type, - id: id, + id: BuiltValueNullFieldError.checkNotNull(id, 'Enrollment', 'id'), courseId: courseId, courseSectionId: courseSectionId, - enrollmentState: enrollmentState, - userId: userId, + enrollmentState: BuiltValueNullFieldError.checkNotNull( + enrollmentState, 'Enrollment', 'enrollmentState'), + userId: BuiltValueNullFieldError.checkNotNull( + userId, 'Enrollment', 'userId'), grades: _grades?.build(), computedCurrentScore: computedCurrentScore, computedFinalScore: computedFinalScore, computedCurrentGrade: computedCurrentGrade, computedFinalGrade: computedFinalGrade, - multipleGradingPeriodsEnabled: multipleGradingPeriodsEnabled, - totalsForAllGradingPeriodsOption: + computedCurrentLetterGrade: computedCurrentLetterGrade, + multipleGradingPeriodsEnabled: BuiltValueNullFieldError.checkNotNull( + multipleGradingPeriodsEnabled, 'Enrollment', 'multipleGradingPeriodsEnabled'), + totalsForAllGradingPeriodsOption: BuiltValueNullFieldError.checkNotNull( totalsForAllGradingPeriodsOption, + 'Enrollment', + 'totalsForAllGradingPeriodsOption'), currentPeriodComputedCurrentScore: currentPeriodComputedCurrentScore, currentPeriodComputedFinalScore: currentPeriodComputedFinalScore, @@ -736,9 +728,13 @@ class EnrollmentBuilder implements Builder { currentPeriodComputedFinalGrade: currentPeriodComputedFinalGrade, currentGradingPeriodId: currentGradingPeriodId, currentGradingPeriodTitle: currentGradingPeriodTitle, - associatedUserId: associatedUserId, + associatedUserId: BuiltValueNullFieldError.checkNotNull( + associatedUserId, 'Enrollment', 'associatedUserId'), lastActivityAt: lastActivityAt, - limitPrivilegesToCourseSection: limitPrivilegesToCourseSection, + limitPrivilegesToCourseSection: BuiltValueNullFieldError.checkNotNull( + limitPrivilegesToCourseSection, + 'Enrollment', + 'limitPrivilegesToCourseSection'), observedUser: _observedUser?.build(), user: _user?.build()); } catch (_) { @@ -762,4 +758,4 @@ class EnrollmentBuilder implements Builder { } } -// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/grade_cell_data.dart b/apps/flutter_parent/lib/models/grade_cell_data.dart index 4dde7ac3b7..b9c0d5d11f 100644 --- a/apps/flutter_parent/lib/models/grade_cell_data.dart +++ b/apps/flutter_parent/lib/models/grade_cell_data.dart @@ -16,6 +16,7 @@ import 'package:built_value/built_value.dart'; import 'package:flutter/material.dart' hide Builder; import 'package:flutter/rendering.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; +import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/submission.dart'; import 'package:flutter_parent/utils/core_extensions/date_time_extensions.dart'; import 'package:flutter_parent/utils/design/parent_colors.dart'; @@ -61,21 +62,31 @@ abstract class GradeCellData implements Built b ..state = GradeCellState.submitted ..submissionText = submission.submittedAt.l10nFormat( @@ -88,7 +99,7 @@ abstract class GradeCellData implements Built 0.0) { @@ -134,18 +150,25 @@ abstract class GradeCellData implements Built b - ..state = GradeCellState.graded - ..graphPercent = graphPercent - ..accentColor = accentColor - ..score = score - ..showPointsLabel = true - ..outOf = outOfText - ..grade = grade - ..gradeContentDescription = accessibleGradeString - ..latePenalty = latePenalty - ..finalGrade = finalGrade); + return restrictQuantitativeData + ? GradeCellData((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..accentColor = accentColor + ..score = restrictedScore + ..gradeContentDescription = accessibleGradeString) + : GradeCellData((b) => b + ..state = GradeCellState.graded + ..graphPercent = graphPercent + ..accentColor = accentColor + ..score = score + ..showPointsLabel = true + ..outOf = outOfText + ..grade = grade + ..gradeContentDescription = accessibleGradeString + ..latePenalty = latePenalty + ..finalGrade = finalGrade); } } -enum GradeCellState { empty, submitted, graded } +enum GradeCellState { empty, submitted, graded, gradedRestrictQuantitativeData } diff --git a/apps/flutter_parent/lib/models/grading_scheme_item.dart b/apps/flutter_parent/lib/models/grading_scheme_item.dart new file mode 100644 index 0000000000..64010dda6b --- /dev/null +++ b/apps/flutter_parent/lib/models/grading_scheme_item.dart @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import 'package:built_value/built_value.dart'; +import 'package:built_value/json_object.dart'; +import 'package:built_value/serializer.dart'; + +part 'grading_scheme_item.g.dart'; + +abstract class GradingSchemeItem implements Built { + static Serializer get serializer => _$gradingSchemeItemSerializer; + + GradingSchemeItem._(); + + factory GradingSchemeItem([void Function(GradingSchemeItemBuilder) updates]) = _$GradingSchemeItem; + + factory GradingSchemeItem.fromJson(JsonObject json) { + if (!json.isList) return null; + List items = json.asList; + if (!(items[0] is String) || !(items[1] is num)) return null; + String grade = items[0] as String; + double value = (items[1] as num).toDouble(); + return GradingSchemeItem((b) => b + ..grade = grade + ..value = value); + } + + String get grade; + + double get value; +} diff --git a/apps/flutter_parent/lib/models/grading_scheme_item.g.dart b/apps/flutter_parent/lib/models/grading_scheme_item.g.dart new file mode 100644 index 0000000000..684f7dc435 --- /dev/null +++ b/apps/flutter_parent/lib/models/grading_scheme_item.g.dart @@ -0,0 +1,154 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'grading_scheme_item.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer _$gradingSchemeItemSerializer = + new _$GradingSchemeItemSerializer(); + +class _$GradingSchemeItemSerializer + implements StructuredSerializer { + @override + final Iterable types = const [GradingSchemeItem, _$GradingSchemeItem]; + @override + final String wireName = 'GradingSchemeItem'; + + @override + Iterable serialize(Serializers serializers, GradingSchemeItem object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'grade', + serializers.serialize(object.grade, + specifiedType: const FullType(String)), + 'value', + serializers.serialize(object.value, + specifiedType: const FullType(double)), + ]; + + return result; + } + + @override + GradingSchemeItem deserialize( + Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new GradingSchemeItemBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current as String; + iterator.moveNext(); + final Object value = iterator.current; + switch (key) { + case 'grade': + result.grade = serializers.deserialize(value, + specifiedType: const FullType(String)) as String; + break; + case 'value': + result.value = serializers.deserialize(value, + specifiedType: const FullType(double)) as double; + break; + } + } + + return result.build(); + } +} + +class _$GradingSchemeItem extends GradingSchemeItem { + @override + final String grade; + @override + final double value; + + factory _$GradingSchemeItem( + [void Function(GradingSchemeItemBuilder) updates]) => + (new GradingSchemeItemBuilder()..update(updates)).build(); + + _$GradingSchemeItem._({this.grade, this.value}) : super._() { + BuiltValueNullFieldError.checkNotNull(grade, 'GradingSchemeItem', 'grade'); + BuiltValueNullFieldError.checkNotNull(value, 'GradingSchemeItem', 'value'); + } + + @override + GradingSchemeItem rebuild(void Function(GradingSchemeItemBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + GradingSchemeItemBuilder toBuilder() => + new GradingSchemeItemBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is GradingSchemeItem && + grade == other.grade && + value == other.value; + } + + @override + int get hashCode { + return $jf($jc($jc(0, grade.hashCode), value.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper('GradingSchemeItem') + ..add('grade', grade) + ..add('value', value)) + .toString(); + } +} + +class GradingSchemeItemBuilder + implements Builder { + _$GradingSchemeItem _$v; + + String _grade; + String get grade => _$this._grade; + set grade(String grade) => _$this._grade = grade; + + double _value; + double get value => _$this._value; + set value(double value) => _$this._value = value; + + GradingSchemeItemBuilder(); + + GradingSchemeItemBuilder get _$this { + final $v = _$v; + if ($v != null) { + _grade = $v.grade; + _value = $v.value; + _$v = null; + } + return this; + } + + @override + void replace(GradingSchemeItem other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$GradingSchemeItem; + } + + @override + void update(void Function(GradingSchemeItemBuilder) updates) { + if (updates != null) updates(this); + } + + @override + _$GradingSchemeItem build() { + final _$result = _$v ?? + new _$GradingSchemeItem._( + grade: BuiltValueNullFieldError.checkNotNull( + grade, 'GradingSchemeItem', 'grade'), + value: BuiltValueNullFieldError.checkNotNull( + value, 'GradingSchemeItem', 'value')); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new diff --git a/apps/flutter_parent/lib/models/serializers.dart b/apps/flutter_parent/lib/models/serializers.dart index ee17cf2e00..1b3c08e964 100644 --- a/apps/flutter_parent/lib/models/serializers.dart +++ b/apps/flutter_parent/lib/models/serializers.dart @@ -16,6 +16,7 @@ library serializers; import 'package:built_collection/built_collection.dart'; import 'package:built_value/iso_8601_date_time_serializer.dart'; +import 'package:built_value/json_object.dart'; import 'package:built_value/serializer.dart'; import 'package:built_value/standard_json_plugin.dart'; import 'package:flutter_parent/models/account_creation_models/create_account_post_body.dart'; @@ -47,6 +48,7 @@ import 'package:flutter_parent/models/enrollment.dart'; import 'package:flutter_parent/models/grade.dart'; import 'package:flutter_parent/models/grading_period.dart'; import 'package:flutter_parent/models/grading_period_response.dart'; +import 'package:flutter_parent/models/grading_scheme_item.dart'; import 'package:flutter_parent/models/help_link.dart'; import 'package:flutter_parent/models/help_links.dart'; import 'package:flutter_parent/models/lock_info.dart'; @@ -172,6 +174,7 @@ part 'serializers.g.dart'; User, UserColors, UserNameData, + GradingSchemeItem, ]) final Serializers _serializers = _$_serializers; diff --git a/apps/flutter_parent/lib/models/serializers.g.dart b/apps/flutter_parent/lib/models/serializers.g.dart index 3847979f9c..0806afda58 100644 --- a/apps/flutter_parent/lib/models/serializers.g.dart +++ b/apps/flutter_parent/lib/models/serializers.g.dart @@ -49,6 +49,7 @@ Serializers _$_serializers = (new Serializers().toBuilder() ..add(GradeSubmissionWrapper.serializer) ..add(GradingPeriod.serializer) ..add(GradingPeriodResponse.serializer) + ..add(GradingSchemeItem.serializer) ..add(GradingType.serializer) ..add(HelpLink.serializer) ..add(HelpLinks.serializer) @@ -114,6 +115,9 @@ Serializers _$_serializers = (new Serializers().toBuilder() ..addBuilderFactory( const FullType(BuiltList, const [const FullType(Section)]), () => new ListBuilder
()) + ..addBuilderFactory( + const FullType(BuiltList, const [const FullType(JsonObject)]), + () => new ListBuilder()) ..addBuilderFactory( const FullType(BuiltList, const [const FullType(GradingPeriod)]), () => new ListBuilder()) diff --git a/apps/flutter_parent/lib/network/api/course_api.dart b/apps/flutter_parent/lib/network/api/course_api.dart index ebf8bd4815..9ce9f502c7 100644 --- a/apps/flutter_parent/lib/network/api/course_api.dart +++ b/apps/flutter_parent/lib/network/api/course_api.dart @@ -37,6 +37,8 @@ class CourseApi { 'course_image', 'sections', 'observed_users', + 'settings', + 'grading_scheme' ], 'enrollment_state': 'active', }; @@ -56,6 +58,8 @@ class CourseApi { 'current_grading_period_scores', 'course_image', 'observed_users', + 'settings', + 'grading_scheme' ] }; return fetch(canvasDio(forceRefresh: forceRefresh).get('courses/${courseId}', queryParameters: params)); diff --git a/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart b/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart index 0d138d5b71..be97b34e04 100644 --- a/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart +++ b/apps/flutter_parent/lib/screens/alerts/alerts_interactor.dart @@ -16,11 +16,14 @@ import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/alert_threshold.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/service_locator.dart'; class AlertsInteractor { Future getAlertsForStudent(String studentId, bool forceRefresh) async { - final alertsFuture = _alertsApi().getAlertsDepaginated(studentId, forceRefresh)?.then((list) => list + final alertsFuture = _alertsApi().getAlertsDepaginated(studentId, forceRefresh)?.then((List list) async { + return locator().filterAlerts(list); + })?.then((list) => list ..sort((a, b) { if (a.actionDate == null && b.actionDate == null) return 0; if (a.actionDate == null && b.actionDate != null) return -1; diff --git a/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart b/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart index a083796e09..0d81ef7334 100644 --- a/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart +++ b/apps/flutter_parent/lib/screens/assignments/assignment_details_interactor.dart @@ -29,7 +29,7 @@ class AssignmentDetailsInteractor { String assignmentId, String studentId, ) async { - final course = locator().getCourse(courseId); + final course = locator().getCourse(courseId, forceRefresh: forceRefresh); final assignment = locator().getAssignment(courseId, assignmentId, forceRefresh: forceRefresh); return AssignmentDetails( @@ -44,7 +44,7 @@ class AssignmentDetailsInteractor { String assignmentId, String studentId, ) async { - final course = locator().getCourse(courseId); + final course = locator().getCourse(courseId, forceRefresh: forceRefresh); final quiz = locator().getAssignment(courseId, assignmentId, forceRefresh: forceRefresh); return AssignmentDetails( diff --git a/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart b/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart index 9e1a35842c..72d0fa5a88 100644 --- a/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart +++ b/apps/flutter_parent/lib/screens/assignments/assignment_details_screen.dart @@ -132,6 +132,8 @@ class _AssignmentDetailsScreenState extends State { final textTheme = Theme.of(context).textTheme; final l10n = L10n(context); + final course = snapshot.data.course; + final restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?? false; final assignment = snapshot.data.assignment; final submission = assignment.submission(_currentStudent.id); final fullyLocked = assignment.isFullyLocked; @@ -155,11 +157,12 @@ class _AssignmentDetailsScreenState extends State { titleStyle: textTheme.headline4, child: Row( children: [ - Text(l10n.assignmentTotalPoints(points), - style: textTheme.caption, - semanticsLabel: l10n.assignmentTotalPointsAccessible(points), - key: Key("assignment_details_total_points")), - if (showStatus) SizedBox(width: 16), + if (!restrictQuantitativeData) + Text(l10n.assignmentTotalPoints(points), + style: textTheme.caption, + semanticsLabel: l10n.assignmentTotalPointsAccessible(points), + key: Key("assignment_details_total_points")), + if (showStatus && !restrictQuantitativeData) SizedBox(width: 16), if (showStatus) _statusIcon(submitted, submittedColor), if (showStatus) SizedBox(width: 8), if (showStatus) @@ -182,7 +185,7 @@ class _AssignmentDetailsScreenState extends State { style: textTheme.subtitle1, key: Key("assignment_details_due_date")), ), ], - GradeCell.forSubmission(context, assignment, submission), + GradeCell.forSubmission(context, course, assignment, submission), ..._lockedRow(assignment), Divider(), ..._rowTile( diff --git a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart index fa0e851a42..1f74cdfb8b 100644 --- a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart +++ b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart @@ -15,6 +15,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; +import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/grade_cell_data.dart'; import 'package:flutter_parent/models/submission.dart'; import 'package:flutter_parent/utils/design/parent_theme.dart'; @@ -27,10 +28,12 @@ class GradeCell extends StatelessWidget { GradeCell.forSubmission( BuildContext context, + Course course, Assignment assignment, Submission submission, { Key key, }) : data = GradeCellData.forSubmission( + course, assignment, submission, Theme.of(context), @@ -75,7 +78,9 @@ class GradeCell extends StatelessWidget { } Widget _graded(BuildContext context, GradeCellData data) { + final bool _isGradedRestrictQuantitativeData = data.state == GradeCellState.gradedRestrictQuantitativeData; return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, key: Key('grade-cell-graded-container'), children: [ Stack( @@ -129,8 +134,8 @@ class GradeCell extends StatelessWidget { ), ], ), - SizedBox(width: 16), - Expanded( + if (!_isGradedRestrictQuantitativeData) SizedBox(width: 16), + if (!_isGradedRestrictQuantitativeData) Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/apps/flutter_parent/lib/screens/courses/courses_screen.dart b/apps/flutter_parent/lib/screens/courses/courses_screen.dart index 7541a9319d..47e1c3401e 100644 --- a/apps/flutter_parent/lib/screens/courses/courses_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/courses_screen.dart @@ -123,9 +123,8 @@ class _CoursesScreenState extends State { var format = NumberFormat.percentPattern(); format.maximumFractionDigits = 2; - if (grade.isCourseGradeLocked( - forAllGradingPeriods: course?.enrollments?.any((enrollment) => enrollment.hasActiveGradingPeriod()) != true, - )) { + if (grade.isCourseGradeLocked(forAllGradingPeriods: course?.enrollments?.any((enrollment) => enrollment.hasActiveGradingPeriod()) != true) || + (course?.settings?.restrictQuantitativeData == true && grade.currentGrade() == null)) { return null; } // If there is no current grade, return 'No grade' @@ -133,7 +132,9 @@ class _CoursesScreenState extends State { // or a score var text = grade.noCurrentGrade() ? L10n(context).noGrade - : grade.currentGrade()?.isNotEmpty == true ? grade.currentGrade() : format.format(grade.currentScore() / 100); + : grade.currentGrade()?.isNotEmpty == true + ? grade.currentGrade() + : format.format(grade.currentScore() / 100); return Text( text, diff --git a/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart b/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart index 60eea98f46..b14a450c75 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_details_model.dart @@ -77,7 +77,7 @@ class CourseDetailsModel extends BaseModel { Future loadAssignments({bool forceRefresh = false}) async { if (forceRefresh) { - course = await _interactor().loadCourse(courseId); + course = await _interactor().loadCourse(courseId, forceRefresh: forceRefresh); } final groupFuture = _interactor() @@ -180,6 +180,8 @@ class CourseDetailsModel extends BaseModel { bool get showSummary => hasHomePageAsSyllabus && (courseSettings?.courseSummary == true); + bool get restrictQuantitativeData => courseSettings?.restrictQuantitativeData == true; + GradingPeriod currentGradingPeriod() => _currentGradingPeriod; /// This sets the next grading period to use when loadAssignments is called. [currentGradingPeriod] won't be updated diff --git a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart index bbe10a671c..21ec29988d 100644 --- a/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart +++ b/apps/flutter_parent/lib/screens/courses/details/course_grades_screen.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; import 'package:flutter_parent/models/assignment_group.dart'; +import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/course_grade.dart'; import 'package:flutter_parent/models/enrollment.dart'; import 'package:flutter_parent/models/grading_period.dart'; @@ -112,12 +113,12 @@ class _CourseGradesScreenState extends State with AutomaticK return ListView( children: [ header, - ..._assignmentListChildren(context, snapshot.data.assignmentGroups), + ..._assignmentListChildren(context, snapshot.data.assignmentGroups, model.course), ], ); } - List _assignmentListChildren(BuildContext context, List groups) { + List _assignmentListChildren(BuildContext context, List groups, Course course) { final children = List(); for (AssignmentGroup group in groups) { @@ -149,7 +150,7 @@ class _CourseGradesScreenState extends State with AutomaticK ), children: [ ...(group.assignments.toList()..sort((a, b) => a.position.compareTo(b.position))) - .map((assignment) => _AssignmentRow(assignment: assignment)) + .map((assignment) => _AssignmentRow(assignment: assignment, course: course)) ], ), ), @@ -246,6 +247,8 @@ class _CourseGradeHeader extends StatelessWidget { // Don't show the total if the grade is locked if (grade.isCourseGradeLocked(forAllGradingPeriods: model.currentGradingPeriod()?.id == null)) return null; + if ((model.courseSettings?.restrictQuantitativeData ?? false) && (grade.currentGrade() == null || grade.currentGrade().isEmpty)) return null; + final textTheme = Theme.of(context).textTheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -275,8 +278,9 @@ class _CourseGradeHeader extends StatelessWidget { class _AssignmentRow extends StatelessWidget { final Assignment assignment; + final Course course; - const _AssignmentRow({Key key, this.assignment}) : super(key: key); + const _AssignmentRow({Key key, this.assignment, this.course}) : super(key: key); @override Widget build(BuildContext context) { @@ -371,15 +375,33 @@ class _AssignmentRow extends StatelessWidget { final localizations = L10n(context); final submission = assignment.submission(studentId); + + final restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?? false; + if (submission?.excused ?? false) { - text = localizations.gradeFormatScoreOutOfPointsPossible(localizations.excused, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible('', points); + text = restrictQuantitativeData + ? localizations.excused + : localizations.gradeFormatScoreOutOfPointsPossible(localizations.excused, points); + semantics = restrictQuantitativeData + ? localizations.excused + : localizations.contentDescriptionScoreOutOfPointsPossible(localizations.excused, points); } else if (submission?.grade != null) { - text = localizations.gradeFormatScoreOutOfPointsPossible(submission.grade, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible(submission.grade, points); + String grade = restrictQuantitativeData && assignment.isGradingTypeQuantitative() + ? course.convertScoreToLetterGrade(submission.score, assignment.pointsPossible) + : submission.grade; + text = restrictQuantitativeData + ? grade + : localizations.gradeFormatScoreOutOfPointsPossible(grade, points); + semantics = restrictQuantitativeData + ? grade + : localizations.contentDescriptionScoreOutOfPointsPossible(grade, points); } else { - text = localizations.gradeFormatScoreOutOfPointsPossible(localizations.assignmentNoScore, points); - semantics = localizations.contentDescriptionScoreOutOfPointsPossible('', points); // Read as "out of x points" + text = restrictQuantitativeData + ? localizations.assignmentNoScore + : localizations.gradeFormatScoreOutOfPointsPossible(localizations.assignmentNoScore, points); + semantics = restrictQuantitativeData + ? '' + : localizations.contentDescriptionScoreOutOfPointsPossible('', points); } return Text(text, diff --git a/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart b/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart index 83be5c16dd..13effba602 100644 --- a/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart +++ b/apps/flutter_parent/lib/screens/dashboard/alert_notifier.dart @@ -13,7 +13,9 @@ // along with this program. If not, see . import 'package:flutter/material.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/service_locator.dart'; class AlertCountNotifier extends ValueNotifier { @@ -21,8 +23,10 @@ class AlertCountNotifier extends ValueNotifier { update(String studentId) async { try { - final unreadCount = await locator().getUnreadCount(studentId); - value = unreadCount?.count?.asNum; + final unreadAlerts = await locator().getAlertsDepaginated(studentId, true)?.then((List list) async { + return await locator().filterAlerts(list.where((element) => element.workflowState == AlertWorkflowState.unread).toList()); + }); + value = unreadAlerts.length; } catch (e) { print(e); } diff --git a/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart b/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart index 911f5db614..1cdbf7b6e8 100644 --- a/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart +++ b/apps/flutter_parent/lib/screens/inbox/create_conversation/create_conversation_screen.dart @@ -463,7 +463,7 @@ class _CreateConversationScreenState extends State wit style: Theme.of(context).textTheme.bodyText1, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( - hintText: L10n(context).messageSubjectInputHint, + labelText: L10n(context).messageSubjectInputHint, contentPadding: EdgeInsets.all(16), border: InputBorder.none, ), @@ -484,7 +484,7 @@ class _CreateConversationScreenState extends State wit maxLines: null, style: Theme.of(context).textTheme.bodyText2, decoration: InputDecoration( - hintText: L10n(context).messageBodyInputHint, + labelText: L10n(context).messageBodyInputHint, contentPadding: EdgeInsets.all(16), border: InputBorder.none, ), diff --git a/apps/flutter_parent/lib/screens/login_landing_screen.dart b/apps/flutter_parent/lib/screens/login_landing_screen.dart index 7c66ea9cf9..af81ff724f 100644 --- a/apps/flutter_parent/lib/screens/login_landing_screen.dart +++ b/apps/flutter_parent/lib/screens/login_landing_screen.dart @@ -18,19 +18,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/login.dart'; import 'package:flutter_parent/models/school_domain.dart'; -import 'package:flutter_parent/network/utils/analytics.dart'; import 'package:flutter_parent/network/utils/api_prefs.dart'; import 'package:flutter_parent/router/panda_router.dart'; import 'package:flutter_parent/screens/qr_login/qr_login_util.dart'; import 'package:flutter_parent/screens/web_login/web_login_screen.dart'; import 'package:flutter_parent/utils/common_widgets/avatar.dart'; -import 'package:flutter_parent/utils/common_widgets/error_report/error_report_dialog.dart'; -import 'package:flutter_parent/utils/common_widgets/error_report/error_report_interactor.dart'; -import 'package:flutter_parent/utils/common_widgets/full_screen_scroll_container.dart'; import 'package:flutter_parent/utils/common_widgets/two_finger_double_tap_gesture_detector.dart'; import 'package:flutter_parent/utils/common_widgets/user_name.dart'; import 'package:flutter_parent/utils/debug_flags.dart'; -import 'package:flutter_parent/utils/design/canvas_icons.dart'; import 'package:flutter_parent/utils/design/canvas_icons_solid.dart'; import 'package:flutter_parent/utils/design/parent_colors.dart'; import 'package:flutter_parent/utils/design/parent_theme.dart'; @@ -93,13 +88,14 @@ class LoginLandingScreen extends StatelessWidget { Widget _body(BuildContext context) { final lastLoginAccount = ApiPrefs.getLastAccount(); + final assetString = ParentTheme.of(context).isDarkMode ? 'assets/svg/canvas-parent-login-logo-dark.svg' : 'assets/svg/canvas-parent-login-logo.svg'; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Spacer(), SvgPicture.asset( - 'assets/svg/canvas-parent-login-logo.svg', + assetString, semanticsLabel: L10n(context).canvasLogoLabel, ), Spacer(), diff --git a/apps/flutter_parent/lib/screens/settings/settings_interactor.dart b/apps/flutter_parent/lib/screens/settings/settings_interactor.dart index 74dffb7968..652a26fc44 100644 --- a/apps/flutter_parent/lib/screens/settings/settings_interactor.dart +++ b/apps/flutter_parent/lib/screens/settings/settings_interactor.dart @@ -96,6 +96,7 @@ class SettingsInteractor { SvgPicture.asset( 'assets/svg/ic_instructure_logo.svg', alignment: Alignment.bottomCenter, + semanticsLabel: L10n(context).aboutLogoSemanticsLabel, ) ], ), diff --git a/apps/flutter_parent/lib/utils/alert_helper.dart b/apps/flutter_parent/lib/utils/alert_helper.dart new file mode 100644 index 0000000000..cdd947e54f --- /dev/null +++ b/apps/flutter_parent/lib/utils/alert_helper.dart @@ -0,0 +1,36 @@ +// Copyright (C) 2023 - present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:flutter_parent/models/alert.dart'; +import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/network/api/course_api.dart'; +import 'package:flutter_parent/utils/service_locator.dart'; + +class AlertsHelper { + Future> filterAlerts(List list) async { + List filteredList = []; + for (var element in list) { + var courseId = element.getCourseIdForGradeAlerts(); + if (courseId == null) { + filteredList.add(element); + } else { + Course course = await locator().getCourse(courseId, forceRefresh: false); + if (!(course.settings?.restrictQuantitativeData ?? false)) { + filteredList.add(element); + } + } + } + return filteredList; + } +} diff --git a/apps/flutter_parent/lib/utils/service_locator.dart b/apps/flutter_parent/lib/utils/service_locator.dart index 778263ba67..4165047df5 100644 --- a/apps/flutter_parent/lib/utils/service_locator.dart +++ b/apps/flutter_parent/lib/utils/service_locator.dart @@ -64,6 +64,7 @@ import 'package:flutter_parent/screens/remote_config/remote_config_interactor.da import 'package:flutter_parent/screens/settings/settings_interactor.dart'; import 'package:flutter_parent/screens/splash/splash_screen_interactor.dart'; import 'package:flutter_parent/screens/web_login/web_login_interactor.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/common_widgets/error_report/error_report_interactor.dart'; import 'package:flutter_parent/utils/common_widgets/view_attachment/view_attachment_interactor.dart'; import 'package:flutter_parent/utils/common_widgets/view_attachment/viewers/audio_video_attachment_viewer_interactor.dart'; @@ -173,4 +174,5 @@ void setupLocator() { locator.registerLazySingleton(() => QRLoginUtil()); locator.registerLazySingleton(() => QuickNav()); locator.registerLazySingleton(() => StudentAddedNotifier()); + locator.registerLazySingleton(() => AlertsHelper()); } diff --git a/apps/flutter_parent/pubspec.yaml b/apps/flutter_parent/pubspec.yaml index 021b319332..d43bf36c23 100644 --- a/apps/flutter_parent/pubspec.yaml +++ b/apps/flutter_parent/pubspec.yaml @@ -25,7 +25,7 @@ description: Canvas Parent # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 3.7.0+45 +version: 3.8.1+47 module: androidX: true diff --git a/apps/flutter_parent/test/models/course_test.dart b/apps/flutter_parent/test/models/course_test.dart index c9af1a4519..ef7b2cdee4 100644 --- a/apps/flutter_parent/test/models/course_test.dart +++ b/apps/flutter_parent/test/models/course_test.dart @@ -12,11 +12,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . import 'package:built_collection/built_collection.dart'; +import 'package:built_value/json_object.dart'; import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/course_grade.dart'; import 'package:flutter_parent/models/enrollment.dart'; -import 'package:flutter_parent/models/section.dart'; -import 'package:flutter_parent/models/term.dart'; +import 'package:flutter_parent/models/grading_scheme_item.dart'; import 'package:test/test.dart'; void main() { @@ -111,4 +111,44 @@ void main() { expect(isValid, isFalse); }); }); + + group('gradingScheme', () { + final gradingSchemeBuilder = ListBuilder() + ..add(JsonObject(["A", 0.9])) + ..add(JsonObject(["F", 0.0])); + + final course = _course.rebuild((b) => b..gradingScheme = gradingSchemeBuilder); + + test('returns "F" for 70 percent', () { + expect(course.convertScoreToLetterGrade(70, 100), 'F'); + }); + + test('returns "A" for 90 percent', () { + expect(course.convertScoreToLetterGrade(90, 100), 'A'); + }); + + test('returns empty if max score is 0', () { + expect(course.convertScoreToLetterGrade(10, 0), ''); + }); + + test('returns empty if grading scheme is null or empty', () { + final course = _course.rebuild((b) => b..gradingScheme = null); + expect(course.convertScoreToLetterGrade(10, 0), ''); + }); + + test('grading scheme mapping filters out incorrect items', () { + gradingSchemeBuilder.add(JsonObject("")); + gradingSchemeBuilder.add(JsonObject([1, 0.9])); + gradingSchemeBuilder.add(JsonObject(["C", "3"])); + final course = _course.rebuild((b) => b..gradingScheme = gradingSchemeBuilder); + expect(course.gradingSchemeItems, [ + GradingSchemeItem((b) => b + ..grade = "A" + ..value = 0.9), + GradingSchemeItem((b) => b + ..grade = "F" + ..value = 0.0), + ]); + }); + }); } diff --git a/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart b/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart index 8cc11cb525..00c6a93ff0 100644 --- a/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart +++ b/apps/flutter_parent/test/screens/alerts/alerts_interactor_test.dart @@ -17,6 +17,7 @@ import 'package:flutter_parent/models/alert_threshold.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/alerts/alerts_interactor.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -29,10 +30,12 @@ void main() { final api = MockAlertsApi(); final notifier = MockAlertCountNotifier(); + final alertsHelper = AlertsHelper(); setupTestLocator((_locator) { _locator.registerFactory(() => api); _locator.registerLazySingleton(() => notifier); + _locator.registerLazySingleton(() => alertsHelper); }); setUp(() { diff --git a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart index ca58ab8070..32f6503b76 100644 --- a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart +++ b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart @@ -12,9 +12,12 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/json_object.dart'; import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; +import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/grade_cell_data.dart'; import 'package:flutter_parent/models/submission.dart'; import 'package:flutter_parent/utils/core_extensions/date_time_extensions.dart'; @@ -26,6 +29,7 @@ void main() { Assignment baseAssignment; Submission baseSubmission; GradeCellData baseGradedState; + Course baseCourse; Color accentColor = Colors.pinkAccent; @@ -55,11 +59,20 @@ void main() { ..state = GradeCellState.graded ..accentColor = accentColor ..outOf = 'Out of 100 points'); + + final gradingSchemeBuilder = ListBuilder() + ..add(JsonObject(["A", 0.9])) + ..add(JsonObject(["F", 0.0])); + + baseCourse = Course((b) => b + ..id = '123' + ..settings.restrictQuantitativeData = false + ..gradingScheme = gradingSchemeBuilder); }); test('Returns empty for null submission', () { var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, null, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, null, theme, l10n); expect(actual, expected); }); @@ -69,7 +82,7 @@ void main() { ..graphPercent = 0.85 ..score = '85' ..showPointsLabel = true); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -81,7 +94,7 @@ void main() { ..grade = null ..score = 0.0); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -97,14 +110,14 @@ void main() { l10n.submissionStatusSuccessSubtitle, dateFormat: DateFormat.MMMMd(), )); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); test('Returns Empty state when not submitted and ungraded', () { var submission = Submission((b) => b..assignmentId = '1'); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -114,7 +127,7 @@ void main() { ..graphPercent = 1.0 ..showCompleteIcon = true ..grade = 'Excused'); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -127,7 +140,7 @@ void main() { ..showPointsLabel = true ..grade = '85%' ..gradeContentDescription = '85%'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -138,7 +151,7 @@ void main() { ..graphPercent = 1.0 ..showCompleteIcon = true ..grade = 'Complete'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -152,7 +165,7 @@ void main() { ..graphPercent = 1.0 ..showIncompleteIcon = true ..grade = 'Incomplete'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -167,7 +180,7 @@ void main() { l10n.submissionStatusSuccessSubtitle, dateFormat: DateFormat.MMMMd(), )); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -176,7 +189,7 @@ void main() { ..graphPercent = 0.85 ..score = '85' ..showPointsLabel = true); - var actual = GradeCellData.forSubmission(baseAssignment, baseSubmission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, baseSubmission, theme, l10n); expect(actual, expected); }); @@ -189,7 +202,7 @@ void main() { ..showPointsLabel = true ..grade = 'B+' ..gradeContentDescription = 'B+'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -206,7 +219,7 @@ void main() { ..showPointsLabel = true ..grade = 'A-' ..gradeContentDescription = 'A. minus'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -219,7 +232,7 @@ void main() { ..showPointsLabel = true ..grade = '3.8 GPA' ..gradeContentDescription = '3.8 GPA'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -227,7 +240,7 @@ void main() { var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.notGraded); var submission = Submission((b) => b..assignmentId = '1'); var expected = GradeCellData(); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -242,7 +255,7 @@ void main() { ..showPointsLabel = true ..latePenalty = 'Late penalty (-6)' ..finalGrade = 'Final Grade: 79'); - var actual = GradeCellData.forSubmission(baseAssignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); }); @@ -255,7 +268,7 @@ void main() { ..showPointsLabel = true ..grade = 'B-' ..gradeContentDescription = 'B. minus'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); expect(actual, expected); }); @@ -272,7 +285,103 @@ void main() { ..showPointsLabel = true ..grade = 'B' ..gradeContentDescription = 'B'); - var actual = GradeCellData.forSubmission(assignment, submission, theme, l10n); + var actual = GradeCellData.forSubmission(baseCourse, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns Empty state when quantitative data is restricted, grading type is points, grading scheme is null and not excused', () { + var course = baseCourse.rebuild((b) => b + ..settings.restrictQuantitativeData = true + ..gradingScheme = null); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.points); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = GradeCellData(); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns Empty state when quantitative data is restricted, grading type is percent, grading scheme is null and not excused', () { + var course = baseCourse.rebuild((b) => b + ..settings.restrictQuantitativeData = true + ..gradingScheme = null); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.percent); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = GradeCellData(); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted and grading type is percent and excused', () { + var course = baseCourse.rebuild((b) => b..settings.restrictQuantitativeData = true); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.percent); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A' + ..excused = true); + var expected = baseGradedState.rebuild((b) => b + ..graphPercent = 1.0 + ..grade = l10n.excused + ..outOf = '' + ..showCompleteIcon = true); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted and graded', () { + var course = baseCourse.rebuild((b) => b..settings.restrictQuantitativeData = true); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.letterGrade); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = baseGradedState.rebuild((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..score = submission.grade + ..gradeContentDescription = submission.grade + ..outOf = ''); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted, grading type is percent and grading scheme is given', () { + var course = baseCourse.rebuild((b) => b..settings.restrictQuantitativeData = true); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.percent); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 10.0 + ..grade = 'A'); + var expected = baseGradedState.rebuild((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..score = 'F' + ..outOf = '' + ..gradeContentDescription = 'F'); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); + expect(actual, expected); + }); + + test('Returns correct state when quantitative data is restricted, grading type is point and grading scheme is given', () { + var course = baseCourse.rebuild((b) => b..settings.restrictQuantitativeData = true); + var assignment = baseAssignment.rebuild((b) => b..gradingType = GradingType.points); + var submission = Submission((b) => b + ..assignmentId = '1' + ..score = 90.0 + ..grade = 'A'); + var expected = baseGradedState.rebuild((b) => b + ..state = GradeCellState.gradedRestrictQuantitativeData + ..graphPercent = 1.0 + ..score = 'A' + ..outOf = '' + ..gradeContentDescription = 'A'); + var actual = GradeCellData.forSubmission(course, assignment, submission, theme, l10n); expect(actual, expected); }); } diff --git a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart index 7e03ef1cc9..bf2c9aff64 100644 --- a/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/course_grades_screen_test.dart @@ -15,11 +15,13 @@ import 'dart:convert'; import 'package:built_collection/built_collection.dart'; +import 'package:built_value/json_object.dart'; import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/assignment.dart'; import 'package:flutter_parent/models/assignment_group.dart'; import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/models/course_settings.dart'; import 'package:flutter_parent/models/enrollment.dart'; import 'package:flutter_parent/models/grade.dart'; import 'package:flutter_parent/models/grading_period.dart'; @@ -319,6 +321,64 @@ void main() { expect(find.text(AppLocalizations().assignmentNotSubmittedLabel), findsOneWidget); }); + testWidgetsWithAccessibilityChecks('Shows grade with possible points if not restricted', (tester) async { + final grade = 'FFF'; + final group = _mockAssignmentGroup(assignments: [_mockAssignment(id: '1', pointsPossible: 2.2, submission: _mockSubmission(grade: grade))]); + final enrollment = Enrollment((b) => b..enrollmentState = 'active'); + + final model = CourseDetailsModel(_student, _courseId); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => [group]); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + model.course = _mockCourse(); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + expect(find.text('$grade / 2.2'), findsOneWidget); + }); + + testWidgetsWithAccessibilityChecks('Shows grade without possible points if restricted', (tester) async { + final grade = 'FFF'; + final group = _mockAssignmentGroup(assignments: [_mockAssignment(id: '1', pointsPossible: 2.2, submission: _mockSubmission(grade: grade))]); + final enrollment = Enrollment((b) => b..enrollmentState = 'active'); + + final model = CourseDetailsModel(_student, _courseId); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => [group]); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + model.course = _mockCourse().rebuild((b) => b..settings.restrictQuantitativeData = true); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + expect(find.text('$grade'), findsOneWidget); + }); + + testWidgetsWithAccessibilityChecks('Shows grade by score and grading scheme if restricted', (tester) async { + final group = _mockAssignmentGroup(assignments: [ + _mockAssignment(id: '1', pointsPossible: 10, gradingType: GradingType.points, submission: _mockSubmission(grade: '', score: 1.0)) + ]); + final enrollment = Enrollment((b) => b..enrollmentState = 'active'); + + final model = CourseDetailsModel(_student, _courseId); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => [group]); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + model.course = _mockCourse().rebuild((b) => b..settings.restrictQuantitativeData = true); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + expect(find.text('F'), findsOneWidget); + }); + group('CourseGradeHeader', () { testWidgetsWithAccessibilityChecks('from current score, max 2 digits', (tester) async { final groups = [ @@ -389,6 +449,53 @@ void main() { expect(find.text(AppLocalizations().courseTotalGradeLabel), findsNothing); }); + testWidgetsWithAccessibilityChecks('is not shown when restricted and its a score', (tester) async { + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentScore: 12)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + model.courseSettings = CourseSettings((b) => b..restrictQuantitativeData = true); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + // Verify that we are not showing the course score if restricted + expect(find.text(AppLocalizations().courseTotalGradeLabel), findsNothing); + }); + + testWidgetsWithAccessibilityChecks('is shown when restricted and its a grade', (tester) async { + final grade = 'Big fat F'; + final groups = [ + _mockAssignmentGroup(assignments: [_mockAssignment()]) + ]; + final enrollment = Enrollment((b) => b + ..enrollmentState = 'active' + ..grades = _mockGrade(currentGrade: grade)); + final model = CourseDetailsModel(_student, _courseId); + model.course = _mockCourse(); + model.courseSettings = CourseSettings((b) => b..restrictQuantitativeData = true); + when(interactor.loadAssignmentGroups(_courseId, _studentId, null)).thenAnswer((_) async => groups); + when(interactor.loadGradingPeriods(_courseId)) + .thenAnswer((_) async => GradingPeriodResponse((b) => b..gradingPeriods = BuiltList.of(List()).toBuilder())); + when(interactor.loadEnrollmentsForGradingPeriod(_courseId, _studentId, null)).thenAnswer((_) async => [enrollment]); + + await tester.pumpWidget(_testableWidget(model)); + await tester.pump(); // Build the widget + await tester.pump(); // Let the future finish + + // Verify that we are showing the course grade when restricted + expect(find.text(grade), findsOneWidget); + }); + testWidgetsWithAccessibilityChecks('is shown when looking at a grading period', (tester) async { final groups = [ _mockAssignmentGroup(assignments: [_mockAssignment()]) @@ -652,6 +759,10 @@ void main() { }); } +final _gradingSchemeBuilder = ListBuilder() + ..add(JsonObject(["A", 0.9])) + ..add(JsonObject(["F", 0.0])); + Course _mockCourse() { return Course((b) => b ..id = _courseId @@ -661,7 +772,8 @@ Course _mockCourse() { ..userId = _studentId ..courseId = _courseId ..enrollmentState = 'active') - ]).toBuilder()); + ]).toBuilder() + ..gradingScheme = _gradingSchemeBuilder); } GradeBuilder _mockGrade({double currentScore, double finalScore, String currentGrade, String finalGrade}) { @@ -691,6 +803,7 @@ Assignment _mockAssignment({ Submission submission, DateTime dueAt, double pointsPossible = 0, + GradingType gradingType }) { return Assignment((b) => b ..id = id @@ -699,20 +812,21 @@ Assignment _mockAssignment({ ..assignmentGroupId = groupId ..position = int.parse(id) ..dueAt = dueAt - ..submissionWrapper = SubmissionWrapper( - (b) => b..submissionList = BuiltList.from(submission != null ? [submission] : []).toBuilder()) - .toBuilder() + ..submissionWrapper = + SubmissionWrapper((b) => b..submissionList = BuiltList.from(submission != null ? [submission] : []).toBuilder()).toBuilder() ..pointsPossible = pointsPossible - ..published = true); + ..published = true + ..gradingType = gradingType); } -Submission _mockSubmission({String assignmentId = '', String grade, bool isLate, DateTime submittedAt}) { +Submission _mockSubmission({String assignmentId = '', String grade, bool isLate, DateTime submittedAt, double score}) { return Submission((b) => b ..userId = _studentId ..assignmentId = assignmentId ..grade = grade ..submittedAt = submittedAt - ..isLate = isLate ?? false); + ..isLate = isLate ?? false + ..score = score ?? 0); } Widget _testableWidget(CourseDetailsModel model, {PlatformConfig platformConfig = const PlatformConfig()}) { diff --git a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart index 62c3d22d4c..2160f90269 100644 --- a/apps/flutter_parent/test/screens/courses/courses_screen_test.dart +++ b/apps/flutter_parent/test/screens/courses/courses_screen_test.dart @@ -193,6 +193,69 @@ void main() { final gradeWidget = find.text('90%'); expect(gradeWidget, findsNWidgets(courses.length)); }); + + testWidgetsWithAccessibilityChecks('hides score if there is a grade but no grade string and score is restricted', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentScore: 90)], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = true)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('90%'); + expect(gradeWidget, findsNothing); + }); + + testWidgetsWithAccessibilityChecks('shows score if there is a grade but no grade string and score is not restricted', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentScore: 90)], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = false)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('90%'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); + + testWidgetsWithAccessibilityChecks('shows grade if restricted and its a letter grade', (tester) async { + var student = _mockStudent('1'); + var courses = List.generate( + 1, + (idx) => _mockCourse( + idx.toString(), + enrollments: ListBuilder( + [_mockEnrollment(idx.toString(), userId: student.id, computedCurrentGrade: 'A')], + ), + ).rebuild((b) => b..settings = (b.settings..restrictQuantitativeData = true)), + ); + + _setupLocator(_MockedCoursesInteractor(courses: courses)); + + await tester.pumpWidget(_testableMaterialWidget()); + await tester.pumpAndSettle(); + + final gradeWidget = find.text('A'); + expect(gradeWidget, findsNWidgets(courses.length)); + }); }); group('Interaction', () { diff --git a/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart b/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart index b82b17cf5c..8a22ba3cc8 100644 --- a/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart +++ b/apps/flutter_parent/test/screens/dashboard/alert_notifier_test.dart @@ -12,9 +12,11 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . import 'package:built_value/json_object.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/unread_count.dart'; import 'package:flutter_parent/network/api/alert_api.dart'; import 'package:flutter_parent/screens/dashboard/alert_notifier.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; @@ -23,6 +25,7 @@ import '../../utils/test_helpers/mock_helpers.dart'; void main() { final api = MockAlertsApi(); + final alertsHelper = AlertsHelper(); setUp(() { reset(api); @@ -30,6 +33,7 @@ void main() { setupTestLocator((locator) { locator.registerLazySingleton(() => api); + locator.registerLazySingleton(() => alertsHelper); }); test('calls the API with the provided student id', () async { @@ -37,12 +41,20 @@ void main() { final count = 4; final notifier = AlertCountNotifier(); - when(api.getUnreadCount(studentId)).thenAnswer((_) async => UnreadCount((b) => b..count = JsonObject(count))); + final data = List.generate(4, (index) { + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..alertType = AlertType.unknown + ..lockedForUser = false); + }); + + when(api.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); expect(notifier.value, 0); await notifier.update(studentId); expect(notifier.value, count); - verify(api.getUnreadCount(studentId)).called(1); + verify(api.getAlertsDepaginated(studentId, any)).called(1); }); test('handles null responses', () async { diff --git a/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart b/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart index 5c6c9010fe..a6ecd53f35 100644 --- a/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart +++ b/apps/flutter_parent/test/screens/dashboard/dashboard_screen_test.dart @@ -15,6 +15,7 @@ import 'package:built_value/json_object.dart'; import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; +import 'package:flutter_parent/models/alert.dart'; import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/help_link.dart'; import 'package:flutter_parent/models/login.dart'; @@ -50,6 +51,7 @@ import 'package:flutter_parent/screens/masquerade/masquerade_screen_interactor.d import 'package:flutter_parent/screens/pairing/pairing_util.dart'; import 'package:flutter_parent/screens/settings/settings_interactor.dart'; import 'package:flutter_parent/screens/settings/settings_screen.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; import 'package:flutter_parent/utils/common_widgets/badges.dart'; import 'package:flutter_parent/utils/common_widgets/empty_panda_widget.dart'; import 'package:flutter_parent/utils/db/calendar_filter_db.dart'; @@ -76,6 +78,7 @@ import '../courses/course_summary_screen_test.dart'; void main() { mockNetworkImageResponse(); final analyticsMock = _MockAnalytics(); + final alertsHelper = AlertsHelper(); _setupLocator({MockInteractor interactor, AlertsApi alertsApi, InboxApi inboxApi}) async { await setupTestLocator((locator) { @@ -97,6 +100,7 @@ void main() { locator.registerLazySingleton(() => SelectedStudentNotifier()); locator.registerLazySingleton(() => StudentAddedNotifier()); locator.registerLazySingleton(() => MockAccountsApi()); + locator.registerLazySingleton(() => alertsHelper); }); } @@ -926,68 +930,81 @@ void main() { await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - verify(alertsApi.getUnreadCount(any)).called(1); + verify(alertsApi.getAlertsDepaginated(any, any)).called(1); }); - testWidgetsWithAccessibilityChecks('Inbox count of zero hides badge', (tester) async { + testWidgetsWithAccessibilityChecks('Alerts count of zero hides badge', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)).thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(0)))); + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value([])); await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); // Assert there's no text in the alerts-count expect(find.descendant(of: find.byKey(Key('alerts-count')), matching: find.byType(Text)), findsNothing); }); - testWidgetsWithAccessibilityChecks('Displays Inbox count', (tester) async { + testWidgetsWithAccessibilityChecks('Displays Alert count', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(88)))); + + final date = DateTime.now(); + final data = List.generate(5, (index) { + // Create a list of alerts with dates in ascending order (reversed) + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..actionDate = date.add(Duration(days: index)) + ..lockedForUser = false); + }); + + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); + await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - expect(find.text('88'), findsOneWidget); + expect(find.text('5'), findsOneWidget); }); - testWidgetsWithAccessibilityChecks('Updates Inbox count', (tester) async { + testWidgetsWithAccessibilityChecks('Updates Alert count', (tester) async { final alertsApi = MockAlertsApi(); var interactor = MockInteractor(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(88)))); + + final date = DateTime.now(); + final data = List.generate(5, (index) { + // Create a list of alerts with dates in ascending order (reversed) + return Alert((b) => b + ..id = index.toString() + ..workflowState = AlertWorkflowState.unread + ..actionDate = date.add(Duration(days: index)) + ..lockedForUser = false); + }); + + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.toList())); + await _setupLocator(interactor: interactor, alertsApi: alertsApi); await tester.pumpWidget(_testableMaterialWidget()); await tester.pumpAndSettle(); - // Open the nav drawer - dashboardState(tester).scaffoldKey.currentState.openDrawer(); await tester.pumpAndSettle(); - when(alertsApi.getUnreadCount(any)) - .thenAnswer((_) => Future.value(UnreadCount((b) => b..count = JsonObject(77)))); + when(alertsApi.getAlertsDepaginated(any, any)).thenAnswer((_) => Future.value(data.sublist(0, 4).toList())); interactor.getAlertCountNotifier().update('doesn\'t matter'); await tester.pumpAndSettle(); - expect(find.text('77'), findsOneWidget); + expect(find.text('4'), findsOneWidget); }); }); diff --git a/apps/flutter_parent/test/utils/alert_helper_test.dart b/apps/flutter_parent/test/utils/alert_helper_test.dart new file mode 100644 index 0000000000..d4bc9280b0 --- /dev/null +++ b/apps/flutter_parent/test/utils/alert_helper_test.dart @@ -0,0 +1,185 @@ +// Copyright (C) 2023 - present Instructure, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3 of the License. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:flutter_parent/models/alert.dart'; +import 'package:flutter_parent/models/course.dart'; +import 'package:flutter_parent/models/course_settings.dart'; +import 'package:flutter_parent/network/api/course_api.dart'; +import 'package:flutter_parent/utils/alert_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'test_app.dart'; +import 'test_helpers/mock_helpers.dart'; + +void main() { + final courseApi = MockCourseApi(); + + final course = Course((b) => b..settings = CourseSettings((b) => b..restrictQuantitativeData = false).toBuilder()); + + final restrictedCourse = Course((b) => b..settings = CourseSettings((b) => b..restrictQuantitativeData = true).toBuilder()); + + setupTestLocator((_locator) { + _locator.registerFactory(() => courseApi); + }); + + setUp(() { + reset(courseApi); + }); + + test('filter course grade alerts if restrictQuantitativeData is true in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeLow + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2, 3)); + }); + + test('keep course grade alerts if restrictQuantitativeData is false in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeLow + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(course)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts); + }); + + test('filter assignment grade alerts if restrictQuantitativeData is true in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.assignmentGradeLow + ..htmlUrl = 'https://canvas.instructure.com/courses/1/assignments/1' + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2, 3)); + }); + + test('keep assignment grade alerts if restrictQuantitativeData is false in course settings', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.assignmentGradeLow + ..htmlUrl = 'https://canvas.instructure.com/courses/1/assignments/1' + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.unknown + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(course)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts); + }); + + test('keep non-grade alerts', () async { + List alerts = [ + Alert((b) => b + ..id = '1' + ..contextId = '1' + ..alertType = AlertType.courseGradeHigh + ..lockedForUser = false), + Alert((b) => b + ..id = '2' + ..contextId = '2' + ..alertType = AlertType.assignmentGradeHigh + ..htmlUrl = 'https://canvas.instructure.com/courses/2/assignments/2' + ..lockedForUser = false), + Alert((b) => b + ..id = '3' + ..contextId = '3' + ..alertType = AlertType.assignmentMissing + ..lockedForUser = false), + Alert((b) => b + ..id = '4' + ..contextId = '4' + ..alertType = AlertType.courseAnnouncement + ..lockedForUser = false), + Alert((b) => b + ..id = '5' + ..contextId = '5' + ..alertType = AlertType.institutionAnnouncement + ..lockedForUser = false), + ]; + + final alertsHelper = AlertsHelper(); + + when(courseApi.getCourse(any)).thenAnswer((_) => Future.value(restrictedCourse)); + + expect((await alertsHelper.filterAlerts(alerts)), alerts.sublist(2)); + }); +} diff --git a/apps/student/build.gradle b/apps/student/build.gradle index fa840d55f2..eb671160b8 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 251 - versionName = '6.24.0' + versionCode = 255 + versionName = '6.26.1' vectorDrawables.useSupportLibrary = true multiDexEnabled = true diff --git a/apps/student/flank_e2e_lowres.yml b/apps/student/flank_e2e_lowres.yml index a0595dafff..d7862027cb 100644 --- a/apps/student/flank_e2e_lowres.yml +++ b/apps/student/flank_e2e_lowres.yml @@ -16,7 +16,7 @@ gcloud: - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - model: NexusLowRes - version: 29 + version: 26 locale: en_US orientation: portrait diff --git a/apps/student/flank_e2e_min.yml b/apps/student/flank_e2e_min.yml new file mode 100644 index 0000000000..f494132881 --- /dev/null +++ b/apps/student/flank_e2e_min.yml @@ -0,0 +1,26 @@ +gcloud: + project: delta-essence-114723 +# Use the next two lines to run locally +# app: ./build/outputs/apk/qa/debug/student-qa-debug.apk +# test: ./build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk + app: ./apps/student/build/outputs/apk/qa/debug/student-qa-debug.apk + test: ./apps/student/build/outputs/apk/androidTest/qa/debug/student-qa-debug-androidTest.apk + results-bucket: android-student + auto-google-login: true + use-orchestrator: true + performance-metrics: false + record-video: true + timeout: 60m + test-targets: + - annotation com.instructure.canvas.espresso.E2E + - notAnnotation com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug + device: + - model: Nexus6P + version: 26 + locale: en_US + orientation: portrait + +flank: + testShards: 1 + testRuns: 1 + diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt index d34e9611d7..755499d291 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AnnouncementsE2ETest.kt @@ -63,13 +63,13 @@ class AnnouncementsE2ETest : StudentTest() { courseBrowserPage.selectAnnouncements() Log.d(STEP_TAG,"Assert that ${announcement.title} announcement is displayed.") - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG, "Assert that ${lockedAnnouncement.title} announcement is really locked so that the 'locked' icon is displayed.") - discussionListPage.assertAnnouncementLocked(lockedAnnouncement.title) + announcementListPage.assertAnnouncementLocked(lockedAnnouncement.title) Log.d(STEP_TAG, "Select ${lockedAnnouncement.title} announcement and assert if we are landing on the Discussion Details Page.") - discussionListPage.selectTopic(lockedAnnouncement.title) + announcementListPage.selectTopic(lockedAnnouncement.title) discussionDetailsPage.assertTitleText(lockedAnnouncement.title) Log.d(STEP_TAG, "Assert that the 'Reply' button is not available on a locked announcement. Navigate back to Announcement List Page.") @@ -77,7 +77,7 @@ class AnnouncementsE2ETest : StudentTest() { Espresso.pressBack() Log.d(STEP_TAG,"Select ${announcement.title} announcement and assert if we are landing on the Discussion Details Page.") - discussionListPage.selectTopic(announcement.title) + announcementListPage.selectTopic(announcement.title) discussionDetailsPage.assertTitleText(announcement.title) val replyMessage = "Reply text" @@ -92,36 +92,36 @@ class AnnouncementsE2ETest : StudentTest() { Log.d(STEP_TAG,"Click on Search button and type ${announcement.title} to the search input field.") Espresso.pressBack() - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(announcement.title) + announcementListPage.searchable.clickOnSearchButton() + announcementListPage.searchable.typeToSearchBar(announcement.title) Log.d(STEP_TAG,"Assert that only the matching announcement is displayed on the Discussion List Page.") - discussionListPage.pullToUpdate() - discussionListPage.assertTopicDisplayed(announcement.title) - discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + announcementListPage.pullToUpdate() + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.clickOnClearSearchButton() - discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG,"Type a search value to the search input field which does not much with any of the existing announcements.") - discussionListPage.typeToSearchBar("Non existing announcement title") + announcementListPage.searchable.typeToSearchBar("Non existing announcement title") sleep(3000) //We need this wait here to let make sure the search process has finished. Log.d(STEP_TAG,"Assert that the empty view is displayed and none of the announcements are appearing on the page.") - discussionListPage.assertEmpty() - discussionListPage.assertTopicNotDisplayed(announcement.title) - discussionListPage.assertTopicNotDisplayed(lockedAnnouncement.title) + announcementListPage.assertEmpty() + announcementListPage.assertTopicNotDisplayed(announcement.title) + announcementListPage.assertTopicNotDisplayed(lockedAnnouncement.title) Log.d(STEP_TAG,"Clear search input field value and assert if all the announcements are displayed again on the Discussion List Page.") - discussionListPage.clickOnClearSearchButton() - discussionListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) - discussionListPage.assertTopicDisplayed(announcement.title) + announcementListPage.searchable.clickOnClearSearchButton() + announcementListPage.waitForDiscussionTopicToDisplay(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) Log.d(STEP_TAG,"Refresh the page and assert that after refresh, still all the announcements are displayed.") refresh() - discussionListPage.assertTopicDisplayed(announcement.title) - discussionListPage.assertTopicDisplayed(lockedAnnouncement.title) + announcementListPage.assertTopicDisplayed(announcement.title) + announcementListPage.assertTopicDisplayed(lockedAnnouncement.title) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt index 2d98a6a603..018271a8d4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/AssignmentsE2ETest.kt @@ -386,10 +386,10 @@ class AssignmentsE2ETest: StudentTest() { assignmentListPage.expandCollapseAssignmentGroup("Assignments") Log.d(STEP_TAG, "Click on the 'Search' (magnifying glass) icon at the toolbar.") - assignmentListPage.clickOnSearchButton() + assignmentListPage.searchable.clickOnSearchButton() Log.d(STEP_TAG, "Type the name of the '${missingAssignment.name}' assignment.") - assignmentListPage.typeToSearchBar(missingAssignment.name.drop(5)) + assignmentListPage.searchable.typeToSearchBar(missingAssignment.name.drop(5)) Log.d(STEP_TAG, "Assert that the '${missingAssignment.name}' assignment has been found by previously typed search string.") sleep(3000) // Allow the search input to propagate diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt index 05502815f9..85506cb028 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DiscussionsE2ETest.kt @@ -24,6 +24,7 @@ import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.CourseApiModel import com.instructure.espresso.ViewUtils +import com.instructure.espresso.getCurrentDateInCanvasFormat import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -70,7 +71,7 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Select course: ${course.name}.") dashboardPage.selectCourse(course) - Log.d(STEP_TAG,"Verify that the Discussions and Assignments Tabs are both displayed on the CourseBrowser Page.") + Log.d(STEP_TAG,"Verify that the Discussions and Announcements Tabs are both displayed on the CourseBrowser Page.") courseBrowserPage.assertTabDisplayed("Announcements") courseBrowserPage.assertTabDisplayed("Discussions") @@ -87,8 +88,8 @@ class DiscussionsE2ETest: StudentTest() { Espresso.pressBack() Log.d(STEP_TAG,"Click on the 'Search' button and search for ${announcement2.title}. announcement.") - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(announcement2.title) + discussionListPage.searchable.clickOnSearchButton() + discussionListPage.searchable.typeToSearchBar(announcement2.title) Log.d(STEP_TAG,"Refresh the page. Assert that the searching method is working well, so ${announcement.title} won't be displayed and ${announcement2.title} is displayed.") discussionListPage.pullToUpdate() @@ -96,7 +97,7 @@ class DiscussionsE2ETest: StudentTest() { discussionListPage.assertTopicNotDisplayed(announcement.title) Log.d(STEP_TAG,"Clear the search input field and assert that both announcements, ${announcement.title} and ${announcement2.title} has been diplayed.") - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(announcement.title) discussionListPage.assertTopicDisplayed(announcement2.title) @@ -149,9 +150,14 @@ class DiscussionsE2ETest: StudentTest() { Log.d(STEP_TAG,"Navigate back to Discussions Page.") Espresso.pressBack() - Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted.") + Log.d(STEP_TAG,"Refresh the page. Assert that the previously sent reply has been counted, and there are no unread replies.") discussionListPage.pullToUpdate() discussionListPage.assertReplyCount(newTopicName, 1) + discussionListPage.assertUnreadReplyCount(newTopicName, 0) + + Log.d(STEP_TAG, "Assert that the due date is the current date (in the expected format).") + val currentDate = getCurrentDateInCanvasFormat() + discussionListPage.assertDueDate(newTopicName, currentDate) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt index 7140ec0967..794c7dc02b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/FilesE2ETest.kt @@ -18,6 +18,7 @@ package com.instructure.student.ui.e2e import android.os.Environment import android.util.Log +import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.models.CanvasContext @@ -28,13 +29,22 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.dataseeding.api.AssignmentsApi import com.instructure.dataseeding.api.DiscussionTopicsApi import com.instructure.dataseeding.api.SubmissionsApi -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.AssignmentApiModel +import com.instructure.dataseeding.model.AttachmentApiModel +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.FileUploadType +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.Randomizer import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.utils.* +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.ViewUtils +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import com.instructure.student.ui.utils.uploadTextFile import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test import java.io.File @@ -174,6 +184,16 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that there is a directory called 'unfiled' is displayed.") fileListPage.assertItemDisplayed("unfiled") // Our discussion attachment goes under "unfiled" + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${discussionAttachmentFile.name}', the file's name to the search input field.") + fileListPage.searchable.clickOnSearchButton() + fileListPage.searchable.typeToSearchBar(discussionAttachmentFile.name) + + Log.d(STEP_TAG, "Assert that only 1 file matches for the search text, and it is '${discussionAttachmentFile.name}', and no directories has been shown in the result. Press search back button the quit from search result view.") + fileListPage.assertSearchResultCount(1) + fileListPage.assertItemDisplayed(discussionAttachmentFile.name) + fileListPage.assertItemNotDisplayed("unfiled") + fileListPage.searchable.pressSearchBackButton() + Log.d(STEP_TAG,"Select 'unfiled' directory. Assert that ${discussionAttachmentFile.name} file is displayed on the File List Page.") fileListPage.selectItem("unfiled") fileListPage.assertItemDisplayed(discussionAttachmentFile.name) @@ -190,6 +210,19 @@ class FilesE2ETest: StudentTest() { Log.d(STEP_TAG,"Assert that empty view is displayed after deletion.") fileListPage.assertViewEmpty() + + Log.d(STEP_TAG, "Navigate back to global File List Page. Assert that the 'unfiled' folder has 0 items because we deleted the only item in it recently.") + Espresso.pressBack() + fileListPage.assertFolderSize("unfiled", 0) + + val testFolderName = "Krissinho's Test Folder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed.") + fileListPage.assertItemDisplayed(testFolderName) } private fun commentOnSubmission( diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt index 6e8c9a8398..1eabb2a7a5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt @@ -44,8 +44,7 @@ class InboxE2ETest: StudentTest() { @E2E @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) - fun testInboxE2E() { - + fun testInboxSelectedButtonActionsE2E() { Log.d(PREPARATION_TAG, "Seeding data.") val data = seedData(students = 2, teachers = 1, courses = 1) val teacher = data.teachersList[0] @@ -76,63 +75,6 @@ class InboxE2ETest: StudentTest() { inboxPage.assertHasConversation() inboxPage.assertConversationDisplayed(seededConversation) - Log.d(STEP_TAG,"Click on 'New Message' button.") - inboxPage.pressNewMessageButton() - - val newMessageSubject = "Hey There" - val newMessage = "Just checking in" - Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") - newMessagePage.populateMessage(course, student2, newMessageSubject, newMessage) - - Log.d(STEP_TAG,"Click on 'Send' button.") - newMessagePage.clickSend() - - Log.d(STEP_TAG,"Click on 'New Message' button.") - inboxPage.pressNewMessageButton() - - val newGroupMessageSubject = "Group Message" - val newGroupMessage = "Testing Group ${group.name}" - Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") - newMessagePage.populateGroupMessage(group, student2, newGroupMessageSubject, newGroupMessage) - - Log.d(STEP_TAG,"Click on 'Send' button.") - newMessagePage.clickSend() - - sleep(2000) // Allow time for messages to propagate - - Log.d(STEP_TAG,"Navigate back to Dashboard Page.") - inboxPage.goToDashboard() - dashboardPage.waitForRender() - - Log.d(STEP_TAG,"Log out with ${student1.name} student.") - leftSideNavigationDrawerPage.logout() - - Log.d(STEP_TAG,"Login with user: ${student2.name}, login id: ${student2.loginId}.") - tokenLogin(student2) - dashboardPage.waitForRender() - - Log.d(STEP_TAG,"Open Inbox Page. Assert that both, the previously seeded 'normal' conversation and the group conversation are displayed.") - dashboardPage.clickInboxTab() - inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationDisplayed(newMessageSubject) - inboxPage.assertConversationDisplayed("Group Message") - - Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") - inboxPage.openConversation(newMessageSubject) - val newReplyMessage = "This is a quite new reply message." - Log.d(STEP_TAG,"Reply to $newGroupMessageSubject conversation with '$newReplyMessage' message. Assert that the reply is displayed.") - inboxConversationPage.replyToMessage(newReplyMessage) - - Log.d(STEP_TAG,"Delete $newReplyMessage reply and assert is has been deleted.") - inboxConversationPage.deleteMessage(newReplyMessage) - inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) - - Log.d(STEP_TAG,"Delete the whole '$newGroupMessageSubject' subject and assert that it has been removed from the conversation list on the Inbox Page.") - inboxConversationPage.deleteConversation() //After deletion we will be navigated back to Inbox Page - inboxPage.assertConversationNotDisplayed(newMessageSubject) - inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationDisplayed("Group Message") - Log.d(STEP_TAG,"Select ${seededConversation.subject} conversation. Assert that is has not been starred already.") inboxPage.openConversation(seededConversation) inboxConversationPage.assertNotStarred() @@ -163,9 +105,8 @@ class InboxE2ETest: StudentTest() { Log.d(STEP_TAG,"Select 'Archived' conversation filter.") inboxPage.filterInbox("Archived") - Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation is displayed by the 'Archived' filter, and other conversations are not displayed.") + Log.d(STEP_TAG,"Assert that ${seededConversation.subject} conversation is displayed by the 'Archived' filter.") inboxPage.assertConversationDisplayed(seededConversation) - inboxPage.assertConversationNotDisplayed("Group Message") Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation. Assert that the selected number of conversations on the toolbar is 1." + "Unarchive it, and assert that it is not displayed in the 'ARCHIVED' scope any more.") @@ -181,79 +122,202 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations (${seededConversation.subject} and $newGroupMessageSubject) and star them." + - "Assert that the selected number of conversations on the toolbar is 2 and both of the has been starred.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) - inboxPage.clickStar() - inboxPage.assertConversationStarred(seededConversation.subject) - inboxPage.assertConversationStarred(newGroupMessageSubject) - - Log.d(STEP_TAG, "Mark them as read (since if at least there is one unread selected, we are showing the 'Mark as Read' icon). Assert that both of them are read.") - inboxPage.clickMarkAsRead() - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.GONE) - - Log.d(STEP_TAG, "Mark them as unread. Assert that both of them will became unread.") - inboxPage.clickMarkAsUnread() - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.VISIBLE) + Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and star it." + + "Assert that the selected number of conversations on the toolbar is 1 and the conversation is starred.") + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.assertSelectedConversationNumber("1") + inboxPage.clickUnstar() + inboxPage.assertConversationNotStarred(seededConversation.subject) - Log.d(STEP_TAG, "Archive both of them. Assert that non of them are displayed in the 'INBOX' scope.") + Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and archive it. Assert that it has not displayed in the 'INBOX' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickArchive() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) sleep(2000) - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that the conversation is displayed there.") inboxPage.filterInbox("Archived") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Navigate to 'UNREAD' scope and assert that none of the conversations are displayed there, because a conversation cannot be archived and unread at the same time.") + Log.d(STEP_TAG, "Navigate to 'UNREAD' scope and assert that the conversation is displayed there, because a conversation cannot be archived and unread at the same time.") inboxPage.filterInbox("Unread") inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that the conversation is NOT displayed there.") inboxPage.filterInbox("Starred") - inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) - - Log.d(STEP_TAG, "Select both of the conversations. Unstar them, and assert that none of them are displayed in the 'STARRED' scope.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) - inboxPage.clickUnstar() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - sleep(2000) + Log.d(STEP_TAG,"Navigate to 'INBOX' scope and assert that ${seededConversation.subject} conversation is NOT displayed because it is archived yet.") + inboxPage.filterInbox("Inbox") + inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope and Select the conversation. Star it, and assert that it has displayed in the 'STARRED' scope.") inboxPage.filterInbox("Archived") - inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.clickStar() + inboxPage.assertConversationStarred(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations. Unarchive them, and assert that none of them are displayed in the 'ARCHIVED' scope.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) + Log.d(STEP_TAG, "Select the conversation. Unarchive it, and assert that it has not displayed in the 'ARCHIVED' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickUnArchive() inboxPage.assertConversationNotDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) + + Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that the conversations is displayed there.") + inboxPage.filterInbox("Starred") + inboxPage.assertConversationDisplayed(seededConversation.subject) sleep(2000) - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and assert that both of the conversations are displayed there.") + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and assert that the conversations is displayed there because it is not archived yet.") inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxMessageComposeReplyAndOptionMenuActionsE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student1.name}, login id: ${student1.loginId}.") + tokenLogin(student1) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox Page. Assert that the previously seeded conversation is displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") + val seededConversation = createConversation(teacher, student1, student2)[0] + + Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") + refresh() + inboxPage.assertHasConversation() + inboxPage.assertConversationDisplayed(seededConversation) + + Log.d(STEP_TAG,"Click on 'New Message' button.") + inboxPage.pressNewMessageButton() - Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right to make it unread. Assert that the conversation became unread.") + val newMessageSubject = "Hey There" + val newMessage = "Just checking in" + Log.d(STEP_TAG,"Create a new message with subject: $newMessageSubject, and message: $newMessage") + newMessagePage.populateMessage(course, student2, newMessageSubject, newMessage) + + Log.d(STEP_TAG,"Click on 'Send' button.") + newMessagePage.clickSend() + + Log.d(STEP_TAG,"Click on 'New Message' button.") + inboxPage.pressNewMessageButton() + + val newGroupMessageSubject = "Group Message" + val newGroupMessage = "Testing Group ${group.name}" + Log.d(STEP_TAG,"Create a new message with subject: $newGroupMessageSubject, and message: $newGroupMessage") + newMessagePage.populateGroupMessage(group, student2, newGroupMessageSubject, newGroupMessage) + + Log.d(STEP_TAG,"Click on 'Send' button.") + newMessagePage.clickSend() + + sleep(2000) // Allow time for messages to propagate + + Log.d(STEP_TAG,"Navigate back to Dashboard Page.") + inboxPage.goToDashboard() + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Log out with ${student1.name} student.") + leftSideNavigationDrawerPage.logout() + + Log.d(STEP_TAG,"Login with user: ${student2.name}, login id: ${student2.loginId}.") + tokenLogin(student2) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Open Inbox Page. Assert that both, the previously seeded 'normal' conversation and the group conversation are displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertConversationDisplayed(seededConversation) + inboxPage.assertConversationDisplayed(newMessageSubject) + inboxPage.assertConversationDisplayed("Group Message") + + Log.d(STEP_TAG,"Select $newGroupMessageSubject conversation.") + inboxPage.openConversation(newMessageSubject) + val newReplyMessage = "This is a quite new reply message." + Log.d(STEP_TAG,"Reply to $newGroupMessageSubject conversation with '$newReplyMessage' message. Assert that the reply is displayed.") + inboxConversationPage.replyToMessage(newReplyMessage) + + Log.d(STEP_TAG,"Delete $newReplyMessage reply and assert is has been deleted.") + inboxConversationPage.deleteMessage(newReplyMessage) + inboxConversationPage.assertMessageNotDisplayed(newReplyMessage) + + Log.d(STEP_TAG,"Delete the whole '$newGroupMessageSubject' subject and assert that it has been removed from the conversation list on the Inbox Page.") + inboxConversationPage.deleteConversation() //After deletion we will be navigated back to Inbox Page + inboxPage.assertConversationNotDisplayed(newMessageSubject) + inboxPage.assertConversationDisplayed(seededConversation) + inboxPage.assertConversationDisplayed("Group Message") + + Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") + inboxPage.filterInbox("Inbox") + inboxPage.selectConversation(newGroupMessageSubject) + + Log.d(STEP_TAG, "Delete the '$newGroupMessageSubject' conversation and assert that it has been removed from the 'INBOX' scope.") + inboxPage.clickDelete() + inboxPage.confirmDelete() + inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) + } + + @E2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.INBOX, TestCategory.E2E) + fun testInboxSwipeGesturesE2E() { + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 2, teachers = 1, courses = 1) + val teacher = data.teachersList[0] + val course = data.coursesList[0] + val student1 = data.studentsList[0] + val student2 = data.studentsList[1] + + val groupCategory = GroupsApi.createCourseGroupCategory(course.id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + Log.d(PREPARATION_TAG, "Create group membership for ${student1.name} and ${student2.name} students to the group: ${group.name}.") + GroupsApi.createGroupMembership(group.id, student1.id, teacher.token) + GroupsApi.createGroupMembership(group.id, student2.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student1.name}, login id: ${student1.loginId}.") + tokenLogin(student1) + dashboardPage.waitForRender() + dashboardPage.assertDisplaysCourse(course) + + Log.d(STEP_TAG,"Open Inbox Page. Assert that the previously seeded conversation is displayed.") + dashboardPage.clickInboxTab() + inboxPage.assertInboxEmpty() + + Log.d(PREPARATION_TAG,"Seed an email from the teacher to ${student1.name} and ${student2.name} students.") + val seededConversation = createConversation(teacher, student1, student2)[0] + + Log.d(STEP_TAG,"Refresh the page. Assert that there is a conversation and it is the previously seeded one.") + refresh() + inboxPage.assertHasConversation() + inboxPage.assertConversationDisplayed(seededConversation) + + Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right to make it read. Assert that the conversation became read.") inboxPage.selectConversation(seededConversation.subject) inboxPage.swipeConversationRight(seededConversation) - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) + inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) - Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right again to make it read. Assert that the conversation became read.") + Log.d(STEP_TAG, "Select '${seededConversation.subject}' conversation and swipe it right again to make it unread. Assert that the conversation became unread.") inboxPage.swipeConversationRight(seededConversation) - inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.GONE) + inboxPage.assertUnreadMarkerVisibility(seededConversation.subject, ViewMatchers.Visibility.VISIBLE) Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' left and assert it is removed from the 'INBOX' scope because it has became archived.") inboxPage.swipeConversationLeft(seededConversation) @@ -271,51 +335,30 @@ class InboxE2ETest: StudentTest() { inboxPage.filterInbox("Inbox") inboxPage.assertConversationDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") - inboxPage.selectConversations(listOf(seededConversation.subject, newGroupMessageSubject)) + Log.d(STEP_TAG, "Select the conversations. Star it and mark it unread. (Preparing for swipe gestures in 'STARRED' and 'UNREAD' scope.") + inboxPage.selectConversations(listOf(seededConversation.subject)) + inboxPage.assertSelectedConversationNumber("1") inboxPage.clickStar() - inboxPage.assertSelectedConversationNumber("2") + inboxPage.assertConversationStarred(seededConversation.subject) inboxPage.clickMarkAsUnread() sleep(1000) - Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that both of the conversation are displayed in the 'STARRED' scope.") + Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that the conversation is displayed in the 'STARRED' scope.") inboxPage.filterInbox("Starred") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationDisplayed(newGroupMessageSubject) Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' left and assert it is removed from the 'STARRED' scope because it has became unstarred.") inboxPage.swipeConversationLeft(seededConversation) inboxPage.assertConversationNotDisplayed(seededConversation.subject) - Log.d(STEP_TAG, "Assert that '$newGroupMessageSubject' conversation is unread.") - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.VISIBLE) - - Log.d(STEP_TAG, "Swipe '$newGroupMessageSubject' conversation right and assert that it has became read.") - inboxPage.swipeConversationRight(newGroupMessageSubject) - inboxPage.assertUnreadMarkerVisibility(newGroupMessageSubject, ViewMatchers.Visibility.GONE) - - Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that only the '${seededConversation.subject}' conversation is displayed in the 'UNREAD' scope.") + Log.d(STEP_TAG, "Navigate to 'UNREAD' scope. Assert that the conversation is displayed in the 'Unread' scope.") inboxPage.filterInbox("Unread") inboxPage.assertConversationDisplayed(seededConversation.subject) - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) - Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' conversation left and assert it has been removed from the 'UNREAD' scope since it has became read.") - inboxPage.swipeConversationLeft(seededConversation) + Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' conversation right and assert that it has disappeared from the 'UNREAD' scope.") + inboxPage.swipeConversationRight(seededConversation.subject) inboxPage.assertConversationNotDisplayed(seededConversation.subject) - - Log.d(STEP_TAG, "Navigate to 'ARCHIVED' scope. Assert that the '${seededConversation.subject}' conversation is displayed in the 'ARCHIVED' scope.") - inboxPage.filterInbox("Archived") - inboxPage.assertConversationDisplayed(seededConversation.subject) - - Log.d(STEP_TAG, "Navigate to 'INBOX' scope and seledct '$newGroupMessageSubject' conversation.") - inboxPage.filterInbox("Inbox") - inboxPage.selectConversation(newGroupMessageSubject) - - Log.d(STEP_TAG, "Delete the '$newGroupMessageSubject' conversation and assert that it has been removed from the 'INBOX' scope.") - inboxPage.clickDelete() - inboxPage.confirmDelete() - inboxPage.assertConversationNotDisplayed(newGroupMessageSubject) } private fun createConversation( diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt index ffb0d5a829..48f911a9a0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ModulesE2ETest.kt @@ -19,8 +19,16 @@ package com.instructure.student.ui.e2e import android.util.Log import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.E2E -import com.instructure.dataseeding.api.* -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.api.AssignmentsApi +import com.instructure.dataseeding.api.DiscussionTopicsApi +import com.instructure.dataseeding.api.ModulesApi +import com.instructure.dataseeding.api.PagesApi +import com.instructure.dataseeding.api.QuizzesApi +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.ModuleApiModel +import com.instructure.dataseeding.model.ModuleItemTypes +import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 @@ -110,10 +118,10 @@ class ModulesE2ETest: StudentTest() { Espresso.pressBack() Log.d(PREPARATION_TAG,"Publish ${module1.name} module.") - updateModule(course, module1, teacher) + publishModule(course, module1, teacher) Log.d(PREPARATION_TAG,"Publish ${module2.name} module.") - updateModule(course, module2, teacher) + publishModule(course, module2, teacher) Log.d(STEP_TAG,"Refresh the page. Assert that the 'Modules' Tab is displayed.") courseBrowserPage.refresh() @@ -133,9 +141,22 @@ class ModulesE2ETest: StudentTest() { modulesPage.assertModuleItemDisplayed(module2, assignment2.name) modulesPage.assertModuleItemDisplayed(module2, page1.title) modulesPage.assertModuleItemDisplayed(module2, discussionTopic1.title) + + Log.d(STEP_TAG, "Collapse the '${module2.name}' module. Assert that there will be 4 countable items on the screen.") + modulesPage.clickOnModuleExpandCollapseIcon(module2.name) + modulesPage.assertModulesAndItemsCount(4) // 2 modules titles and 2 module item in first module + + Log.d(STEP_TAG, "Expand the '${module2.name}' module. Assert that there will be 7 countable items on the screen.") + modulesPage.clickOnModuleExpandCollapseIcon(module2.name) + modulesPage.assertModulesAndItemsCount(7) // 2 modules titles, 2 module items in first module, 3 items in second module + + Log.d(STEP_TAG, "Assert that ${assignment1.name} module item is displayed and open it. Assert that the Assignment Details page is displayed with the corresponding assignment title.") + modulesPage.assertAndClickModuleItem(module1.name, assignment1.name, true) + assignmentDetailsPage.assertPageObjects() + assignmentDetailsPage.assertAssignmentTitle(assignment1.name) } - private fun updateModule( + private fun publishModule( course: CourseApiModel, module1: ModuleApiModel, teacher: CanvasUserApiModel diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt index 44c0552ddf..2f48aaacd3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PagesE2ETest.kt @@ -52,43 +52,90 @@ class PagesE2ETest: StudentTest() { val teacher = data.teachersList[0] val course = data.coursesList[0] - Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for ${course.name} course.") + Log.d(PREPARATION_TAG,"Seed an UNPUBLISHED page for '${course.name}' course.") val pageUnpublished = createCoursePage(course, teacher, published = false, frontPage = false) - Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for ${course.name} course.") - val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED page for '${course.name}' course.") + val pagePublished = createCoursePage(course, teacher, published = true, frontPage = false, editingRoles = "teachers,students", body = "

Regular Page Text

") - Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for ${course.name} course.") - val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, body = "

Front Page Text

") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, but NOT editable page for '${course.name}' course.") + val pageNotEditable = createCoursePage(course, teacher, published = true, frontPage = false, body = "

Regular Page Text

") - Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + Log.d(PREPARATION_TAG,"Seed a PUBLISHED, FRONT page for '${course.name}' course.") + val pagePublishedFront = createCoursePage(course, teacher, published = true, frontPage = true, editingRoles = "public", body = "

Front Page Text

") + + Log.d(STEP_TAG,"Login with user: '${student.name}', login id: '${student.loginId}'.") tokenLogin(student) dashboardPage.waitForRender() - Log.d(STEP_TAG,"Select ${course.name} course and navigate to Modules Page.") + Log.d(STEP_TAG,"Select '${course.name}' course and navigate to Modules Page.") dashboardPage.selectCourse(course) courseBrowserPage.selectPages() - Log.d(STEP_TAG,"Assert that ${pagePublishedFront.title} published front page is displayed.") + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") + pageListPage.assertFrontPageDisplayed(pagePublishedFront) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") + pageListPage.assertRegularPageDisplayed(pagePublished) + + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") + pageListPage.assertPageNotDisplayed(pageUnpublished) + + Log.d(STEP_TAG, "Click on 'Search' (magnifying glass) icon and type '${pagePublishedFront.title}', the page's name to the search input field.") + pageListPage.searchable.clickOnSearchButton() + pageListPage.searchable.typeToSearchBar(pagePublishedFront.title) + + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is NOT displayed and there is only one page (the front page) is displayed.") + pageListPage.assertPageNotDisplayed(pagePublished) + pageListPage.assertPageListItemCount(1) + + Log.d(STEP_TAG, "Click on clear search icon (X).") + pageListPage.searchable.clickOnClearSearchButton() + + Log.d(STEP_TAG,"Assert that '${pagePublishedFront.title}' published front page is displayed.") pageListPage.assertFrontPageDisplayed(pagePublishedFront) - Log.d(STEP_TAG,"Assert that ${pagePublished.title} published page is displayed.") + Log.d(STEP_TAG,"Assert that '${pagePublished.title}' published page is displayed.") pageListPage.assertRegularPageDisplayed(pagePublished) - Log.d(STEP_TAG,"Assert that ${pageUnpublished.title} unpublished page is NOT displayed.") + Log.d(STEP_TAG,"Assert that '${pageUnpublished.title}' unpublished page is NOT displayed.") pageListPage.assertPageNotDisplayed(pageUnpublished) - Log.d(STEP_TAG,"Open ${pagePublishedFront.title} page. Assert that it is really a front (published) page via web view assertions.") + Log.d(STEP_TAG,"Open '${pagePublishedFront.title}' page. Assert that it is really a front (published) page via web view assertions.") pageListPage.selectFrontPage(pagePublishedFront) canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text")) Log.d(STEP_TAG,"Navigate back to Pages page.") Espresso.pressBack() - Log.d(STEP_TAG,"Open ${pagePublished.title} page. Assert that it is really a regular published page via web view assertions.") + Log.d(STEP_TAG, "Select '${pageNotEditable.title}' page. Assert that it is not editable as a student, then navigate back to Page List page.") + pageListPage.selectRegularPage(pageNotEditable) + canvasWebViewPage.assertDoesNotEditable() + Espresso.pressBack() + + Log.d(STEP_TAG,"Open '${pagePublished.title}' page. Assert that it is really a regular published page via web view assertions.") pageListPage.selectRegularPage(pagePublished) canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Regular Page Text")) + Log.d(STEP_TAG, "Click on the 'Pencil' icon and edit the body. Click on 'Save' button.") + canvasWebViewPage.clickEditPencilIcon() + canvasWebViewPage.typeInRCEEditor("

Page Text Mod

") + canvasWebViewPage.clickOnSave() + + Log.d(STEP_TAG, "Assert that the new, edited text is displayed in the page body.") + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Page Text Mod")) + + Log.d(STEP_TAG, "Navigate back to Page List page. Select '${pagePublishedFront.title}' front page.") + Espresso.pressBack() + pageListPage.selectFrontPage(pagePublishedFront) + + Log.d(STEP_TAG, "Click on the 'Pencil' icon and edit the body. Click on 'Save' button.") + canvasWebViewPage.clickEditPencilIcon() + canvasWebViewPage.typeInRCEEditor("

Front Page Text Mod

") + canvasWebViewPage.clickOnSave() + + Log.d(STEP_TAG, "Assert that the new, edited text is displayed in the page body.") + canvasWebViewPage.runTextChecks(WebViewTextCheck(Locator.ID, "header1", "Front Page Text Mod")) } private fun createCoursePage( @@ -96,11 +143,13 @@ class PagesE2ETest: StudentTest() { teacher: CanvasUserApiModel, published: Boolean, frontPage: Boolean, + editingRoles: String? = null, body: String = Randomizer.randomPageBody() ) = PagesApi.createCoursePage( courseId = course.id, published = published, frontPage = frontPage, + editingRoles = editingRoles, token = teacher.token, body = body ) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt index f5900bdbbf..b63a31b4ab 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/PeopleE2ETest.kt @@ -59,16 +59,16 @@ class PeopleE2ETest : StudentTest() { peopleListPage.assertPersonListed(teacher) peopleListPage.assertPersonListed(student1) peopleListPage.assertPersonListed(student2) - peopleListPage.assertPeopleCount(5) //2 for Teachers and Students sections, 1 for teacher user and 2 for student users. + peopleListPage.assertPeopleCount(3) Log.d(STEP_TAG,"Collapse student list and assert that the students are not displayed, but the teacher user is displayed.") peopleListPage.clickOnStudentsExpandCollapseButton() peopleListPage.assertPersonListed(teacher) - peopleListPage.assertPeopleCount(3) //2 for Teachers and Students sections, and 3rd for the teacher user. + peopleListPage.assertPeopleCount(1) peopleListPage.clickOnStudentsExpandCollapseButton() peopleListPage.assertPersonListed(student1) peopleListPage.assertPersonListed(student2) - peopleListPage.assertPeopleCount(5) //2 for Teachers and Students sections, 1 for teacher user and 2 for student users. + peopleListPage.assertPeopleCount(3) Log.d(STEP_TAG,"Select ${student2.name} student and assert if we are landing on the Person Details Page.") peopleListPage.selectPerson(student2) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt index 472584de69..13261cd33e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/QuizzesE2ETest.kt @@ -248,4 +248,5 @@ class QuizzesE2ETest: StudentTest() { // answers = listOf() // ) ) + } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt index b219a02e31..8df03a41d9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/ShareExtensionE2ETest.kt @@ -19,7 +19,6 @@ package com.instructure.student.ui.e2e import android.content.Intent import android.net.Uri import android.util.Log -import androidx.core.content.FileProvider import androidx.test.espresso.Espresso import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice @@ -34,14 +33,12 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test -import java.io.File @HiltAndroidTest class ShareExtensionE2ETest: StudentTest() { @@ -226,20 +223,6 @@ class ShareExtensionE2ETest: StudentTest() { ) } - private fun setupFileOnDevice(fileName: String): Uri { - copyAssetFileToExternalCache(activityRule.activity, fileName) - - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - - val instrumentationContext = InstrumentationRegistry.getInstrumentation().context - return FileProvider.getUriForFile( - instrumentationContext, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - } - private fun shareMultipleFiles(uris: ArrayList) { val intent = Intent().apply { action = Intent.ACTION_SEND_MULTIPLE diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt index 524f3a9c29..e4cdad1e9c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/SyllabusE2ETest.kt @@ -48,7 +48,7 @@ class SyllabusE2ETest: StudentTest() { fun testSyllabusE2E() { Log.d(PREPARATION_TAG, "Seeding data.") - val data = seedData(students = 1, teachers = 1, courses = 1) + val data = seedData(students = 1, teachers = 1, courses = 1, syllabusBody = "this is the syllabus body") val student = data.studentsList[0] val teacher = data.teachersList[0] val course = data.coursesList[0] @@ -60,9 +60,10 @@ class SyllabusE2ETest: StudentTest() { Log.d(STEP_TAG,"Select ${course.name} course.") dashboardPage.selectCourse(course) - Log.d(STEP_TAG,"Navigate to Syllabus Page. Assert that Empty View is displayed, because there is no syllabus yet.") + Log.d(STEP_TAG,"Navigate to Syllabus Page. Assert that the syllabus body string is displayed, and there are no tabs yet.") courseBrowserPage.selectSyllabus() - syllabusPage.assertEmptyView() + syllabusPage.assertNoTabs() + syllabusPage.assertSyllabusBody("this is the syllabus body") Log.d(PREPARATION_TAG,"Seed an assignment for ${course.name} course.") val assignment = createAssignment(course, teacher) @@ -70,10 +71,9 @@ class SyllabusE2ETest: StudentTest() { Log.d(PREPARATION_TAG,"Seed a quiz for ${course.name} course.") val quiz = createQuiz(course, teacher) - // TODO: Seed a generic calendar event - - Log.d(STEP_TAG,"Refresh the page. Assert that all of the items, so ${assignment.name} assignment and ${quiz.title} quiz are displayed.") + Log.d(STEP_TAG,"Refresh the page. Navigate to 'Summary' tab. Assert that all of the items, so ${assignment.name} assignment and ${quiz.title} quiz are displayed.") syllabusPage.refresh() + syllabusPage.selectSummaryTab() syllabusPage.assertItemDisplayed(assignment.name) syllabusPage.assertItemDisplayed(quiz.title) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt index 106a18af78..54c3387d3e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/k5/ImportantDatesE2ETest.kt @@ -93,8 +93,8 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertItemDisplayed(testAssignment3.name) importantDatesPage.assertItemNotDisplayed(testNotImportantAssignment.name) - Log.d(STEP_TAG, "Assert that the count of the items (5) and the day strings are correct on the Important Dates page.") - importantDatesPage.assertRecyclerViewItemCount(5) // We count both day texts and calendar events here, since both types are part of the recyclerView. + Log.d(STEP_TAG, "Assert that the count of the items (3) and the day strings are correct on the Important Dates page.") + importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) @@ -114,8 +114,8 @@ class ImportantDatesE2ETest : StudentTest() { importantDatesPage.assertItemDisplayed(testAssignment3.name) importantDatesPage.assertItemNotDisplayed(testNotImportantAssignment.name) - Log.d(STEP_TAG, "Assert that the count of the items (5) and the day strings are correct on the Important Dates page after the refresh.") - importantDatesPage.assertRecyclerViewItemCount(5) // We count both day texts and calendar events here, since both types are part of the recyclerView. + Log.d(STEP_TAG, "Assert that the count of the items (3) and the day strings are correct on the Important Dates page after the refresh.") + importantDatesPage.assertRecyclerViewItemCount(3) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment1.dueAt.toDate())) importantDatesPage.assertDayTextIsDisplayed(generateDayString(testAssignment2.dueAt.toDate())) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt new file mode 100644 index 0000000000..e9825d4c3d --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/usergroups/UserGroupFilesE2ETest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.instructure.student.ui.e2e.usergroups + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.espresso.intent.Intents +import com.instructure.canvas.espresso.E2E +import com.instructure.dataseeding.api.GroupsApi +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class UserGroupFilesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @E2E + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.E2E, secondaryFeature = SecondaryFeatureCategory.GROUPS_FILES) + fun testUserGroupFileControlFlow() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val teacher = data.teachersList[0] + setupFileOnDevice("samplepdf.pdf") + + Log.d(PREPARATION_TAG,"Seed some group info.") + val groupCategory = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) + val groupCategory2 = GroupsApi.createCourseGroupCategory(data.coursesList[0].id, teacher.token) + val group = GroupsApi.createGroup(groupCategory.id, teacher.token) + val group2 = GroupsApi.createGroup(groupCategory2.id, teacher.token) + + Log.d(PREPARATION_TAG,"Create group membership for ${student.name} student.") + GroupsApi.createGroupMembership(group.id, student.id, teacher.token) + GroupsApi.createGroupMembership(group2.id, student.id, teacher.token) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG,"Assert that ${group.name} groups is displayed.") + dashboardPage.assertDisplaysGroup(group, data.coursesList[0]) + dashboardPage.assertDisplaysGroup(group2, data.coursesList[0]) + + Log.d(STEP_TAG, "Select '${group.name}' group and assert if the group title is correct on the Group Browser Page.") + dashboardPage.selectGroup(group) + groupBrowserPage.assertTitleCorrect(group) + + Log.d(STEP_TAG, "Select 'Files' tab within the Group Browser Page and assert that the File List Page is displayed.") + groupBrowserPage.selectFiles() + fileListPage.assertPageObjects() + + val testFolderName = "OneWordFolder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName' is displayed." + + "Assert that the '$testFolderName' folder's size is 0, because we just created it.") + fileListPage.assertItemDisplayed(testFolderName) + fileListPage.assertFolderSize(testFolderName, 0) + + Log.d(STEP_TAG, "Select '$testFolderName' folder and upload a file named 'samplepdf.pdf' within it.") + fileListPage.selectItem(testFolderName) + fileListPage.clickAddButton() + fileListPage.clickUploadFileButton() + + Intents.init() + try { + stubFilePickerIntent("samplepdf.pdf") + fileUploadPage.chooseDevice() + } + finally { + Intents.release() + } + fileUploadPage.clickUpload() + + Log.d(STEP_TAG, "Assert that the file upload was successful.") + fileListPage.assertItemDisplayed("samplepdf.pdf") + + Log.d(STEP_TAG, "Navigate back to File List Page. Assert that the '$testFolderName' folder's size is 1, because we just uploaded a file in it.") + Espresso.pressBack() + fileListPage.assertFolderSize(testFolderName, 1) + + val testFolderName2 = "TwoWord Folder" + Log.d(STEP_TAG, "Click on Add ('+') button and then the 'Add Folder' icon, and create a new folder with the following name: '$testFolderName2'.") + fileListPage.clickAddButton() + fileListPage.clickCreateNewFolderButton() + fileListPage.createNewFolder(testFolderName2) + + Log.d(STEP_TAG,"Assert that there is a folder called '$testFolderName2' is displayed." + + "Assert that the '$testFolderName2' folder's size is 0, because we just created it.") + fileListPage.assertItemDisplayed(testFolderName2) + fileListPage.assertFolderSize(testFolderName2, 0) + + Log.d(STEP_TAG, "Select '$testFolderName2' folder and assert that the empty view is displayed.") + fileListPage.selectItem(testFolderName2) + fileListPage.assertViewEmpty() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt index a857544b3b..187ff93b34 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AnnouncementInteractionTest.kt @@ -17,8 +17,17 @@ package com.instructure.student.ui.interaction import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addCoursePermissions +import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockCanvas.addGroupToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CanvasContextPermission +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.Group +import com.instructure.canvasapi2.models.Tab +import com.instructure.canvasapi2.models.User import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -219,14 +228,14 @@ class AnnouncementInteractionTest : StudentTest() { discussionListPage.createAnnouncement(testAnnouncementName, "description") discussionListPage.assertAnnouncementCreated(testAnnouncementName) - discussionListPage.clickOnSearchButton() - discussionListPage.typeToSearchBar(testAnnouncementName) + discussionListPage.searchable.clickOnSearchButton() + discussionListPage.searchable.typeToSearchBar(testAnnouncementName) discussionListPage.pullToUpdate() discussionListPage.assertTopicDisplayed(testAnnouncementName) discussionListPage.assertTopicNotDisplayed(existingAnnouncementName) - discussionListPage.clickOnClearSearchButton() + discussionListPage.searchable.clickOnClearSearchButton() discussionListPage.waitForDiscussionTopicToDisplay(existingAnnouncementName!!) discussionListPage.assertTopicDisplayed(testAnnouncementName) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt index c0763e0e9f..b78a0e9db0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentDetailsInteractionTest.kt @@ -21,6 +21,7 @@ import com.instructure.canvas.espresso.mockCanvas.addAssignmentsToGroups import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.utils.toApiString import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority @@ -33,7 +34,7 @@ import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Assert.assertNotNull import org.junit.Test -import java.util.* +import java.util.Calendar @HiltAndroidTest class AssignmentDetailsInteractionTest : StudentTest() { @@ -72,7 +73,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testSubmissionStatus_Missing() { // Test clicking on the Assignment item in the Assignment List to load the Assignment Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter { it.value.submission == null && it.value.dueAt != null && !it.value.isSubmitted } @@ -85,7 +87,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testSubmissionStatus_NotSubmitted() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter {it.value.submission == null && it.value.dueAt == null} val assignmentWithoutSubmission = assignmentWithoutSubmissionEntry.entries.first().value @@ -100,7 +103,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayToolbarTitles() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val testAssignment = assignmentList.entries.first().value val course = data.courses.values.first() @@ -115,7 +119,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.COMMON, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayBookmarMenu() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val testAssignment = assignmentList.entries.first().value @@ -128,7 +133,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testDisplayDueDate() { - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val calendar = Calendar.getInstance().apply { set(2023, 0, 31, 23, 59, 0) } val expectedDueDate = "January 31, 2023 11:59 PM" val course = data.courses.values.first() @@ -143,7 +149,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun testNavigating_viewAssignmentDetails() { // Test clicking on the Assignment item in the Assignment List to load the Assignment Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithSubmissionEntry = assignmentList.filter {it.value.submission != null} val assignmentWithSubmission = assignmentWithSubmissionEntry.entries.first().value @@ -158,7 +165,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION) fun testNavigating_viewSubmissionDetailsWithSubmission() { // Test clicking on the Submission and Rubric button to load the Submission Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithSubmissionEntry = assignmentList.filter {it.value.submission != null} val assignmentWithSubmission = assignmentWithSubmissionEntry.entries.first().value @@ -173,7 +181,8 @@ class AssignmentDetailsInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.SUBMISSIONS, TestCategory.INTERACTION) fun testNavigating_viewSubmissionDetailsWithoutSubmission() { // Test clicking on the Submission and Rubric button to load the Submission Details Page - val data = goToAssignmentFromList() + val data = setUpData() + goToAssignmentList() val assignmentList = data.assignments val assignmentWithoutSubmissionEntry = assignmentList.filter {it.value.submission == null} val assignmentWithoutSubmission = assignmentWithoutSubmissionEntry.entries.first().value @@ -184,17 +193,196 @@ class AssignmentDetailsInteractionTest : StudentTest() { submissionDetailsPage.assertPageObjects() } - private fun goToAssignmentFromList(): MockCanvas { + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + val data = setUpData() + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("B") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("3.7") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeNotDisplayed() + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("EX") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("90%") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 100 pts") + assignmentDetailsPage.assertScoreDisplayed("90") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("Complete") + assignmentDetailsPage.assertOutOfTextDisplayed("Out of 0 pts") + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + val data = setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("B") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("3.7") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "65", 65.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("D") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("EX") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "70%", 70.0, 100) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("C") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentList() + assignmentListPage.clickAssignment(assignment) + + assignmentDetailsPage.assertGradeDisplayed("Complete") + assignmentDetailsPage.assertOutOfTextNotDisplayed() + assignmentDetailsPage.assertScoreNotDisplayed() + } + + private fun setUpData(restrictQuantitativeData: Boolean = false): MockCanvas { // Test clicking on the Submission and Rubric button to load the Submission Details Page val data = MockCanvas.init( studentCount = 1, courseCount = 1 ) + val course = data.courses.values.first() + + val gradingScheme = listOf( + listOf("A", 0.9), + listOf("B", 0.8), + listOf("C", 0.7), + listOf("D", 0.6), + listOf("F", 0.0) + ) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + gradingSchemeRaw = gradingScheme) + data.courses[course.id] = newCourse + + data.addAssignmentsToGroups(newCourse) + + return data + } + + private fun goToAssignmentList() { + val data = MockCanvas.data val course = data.courses.values.first() val student = data.students[0] val token = data.tokenFor(student)!! - val assignmentGroups = data.addAssignmentsToGroups(course) + val assignmentGroups = data.assignmentGroups[course.id]!! + tokenLogin(data.domain, token, student) routeTo("courses/${course.id}/assignments", data.domain) assignmentListPage.waitForPage() @@ -205,8 +393,21 @@ class AssignmentDetailsInteractionTest : StudentTest() { val assignmentWithoutSubmission = assignmentGroups.flatMap { it.assignments }.find {it.submission == null} assertNotNull("Expected at least one assignment with a submission", assignmentWithSubmission) assertNotNull("Expected at least one assignment without a submission", assignmentWithoutSubmission) + } - return data + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + return assignment } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt index c92de79225..b7c95d29ae 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/AssignmentListInteractionTest.kt @@ -17,8 +17,10 @@ package com.instructure.student.ui.interaction import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.student.ui.utils.* import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority @@ -33,28 +35,32 @@ class AssignmentListInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) override fun displaysPageObjects() { - getToAssignmentsPage(0) + setUpData(0) + goToAssignmentsPage() assignmentListPage.assertPageObjects() } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun displaysNoAssignmentsView() { - getToAssignmentsPage(0) + setUpData(0) + goToAssignmentsPage() assignmentListPage.assertDisplaysNoAssignmentsView() } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun displaysAssignment() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.assertHasAssignment(assignment) } @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun sortAssignmentsByTimeByDefault() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.assertHasAssignment(assignment) assignmentListPage.assertSortByButtonShowsSortByTime() assignmentListPage.assertFindsUndatedAssignmentLabel() @@ -63,7 +69,8 @@ class AssignmentListInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION) fun sortAssignmentsByTypeWhenTypeIsSelectedInTheDialog() { - val assignment = getToAssignmentsPage()[0] + val assignment = setUpData()[0] + goToAssignmentsPage() assignmentListPage.selectSortByType() @@ -71,33 +78,189 @@ class AssignmentListInteractionTest : StudentTest() { assignmentListPage.assertSortByButtonShowsSortByType() } - private fun getToAssignmentsPage(assignmentCount: Int = 1): List { + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100 (B)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100 (3.7)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "EX/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "90%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + setUpData() + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Complete") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "3.7") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, "90", 90.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "A") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Excused") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PERCENT, "80%", 80.0, 100) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.ASSIGNMENTS, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + setUpData(restrictQuantitativeData = true) + val assignment = addAssignment(MockCanvas.data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + goToAssignmentsPage() + + assignmentListPage.assertAssignmentDisplayedWithGrade(assignment.name!!, "Complete") + } + + private fun setUpData(assignmentCount: Int = 1, restrictQuantitativeData: Boolean = false): List { val data = MockCanvas.init( - courseCount = 1, - favoriteCourseCount = 1, - studentCount = 1, - teacherCount = 1 + courseCount = 1, + favoriteCourseCount = 1, + studentCount = 1, + teacherCount = 1 ) val course = data.courses.values.first() - val student = data.students.first() + + val gradingScheme = listOf( + listOf("A", 0.9), + listOf("B", 0.8), + listOf("C", 0.7), + listOf("D", 0.6), + listOf("F", 0.0) + ) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + gradingSchemeRaw = gradingScheme) + data.courses[course.id] = newCourse val assignmentList = mutableListOf() repeat(assignmentCount) { val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY ) assignmentList.add(assignment) } + return assignmentList + } + + private fun goToAssignmentsPage() { + val data = MockCanvas.data + + val course = data.courses.values.first() + val student = data.students[0] + val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) dashboardPage.waitForRender() dashboardPage.selectCourse(course) courseBrowserPage.selectAssignments() - return assignmentList + } + + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + + return assignment } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt new file mode 100644 index 0000000000..ec8a032701 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/CourseGradesInteractionTest.kt @@ -0,0 +1,298 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.student.ui.interaction + +import androidx.test.espresso.matcher.ViewMatchers +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addSubmissionForAssignment +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Grades +import com.instructure.canvasapi2.models.Tab +import com.instructure.espresso.page.getStringFromResource +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.R +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class CourseGradesInteractionTest : StudentTest() { + + override fun displaysPageObjects() = Unit + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testTotalGradeIsDisplayedWithGradeAndScoreWhenNotRestricted() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, false) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("100% (A)")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testTotalGradeIsDisplayedWithOnlyScoreWhenNotRestrictedAndThereIsNoGrade() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("100%")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGradeIsDisplayedWithOnlyGradeWhenQuantitativeDataIsRestricted() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, true) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("A")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testConvertedGradeIsDisplayedWithOnlyScoreWhenRestrictedAndThereIsNoGrade() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + goToGrades(data) + courseGradesPage.assertTotalGrade(ViewMatchers.withText("A")) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100 (B)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100 (3.7)") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, "90", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "EX/100") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.PERCENT, "90%", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "90%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithoutQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = false) + val assignment = addAssignment(data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Complete") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testLetterGradeAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.LETTER_GRADE, "B", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testGpaScaleAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.GPA_SCALE, "3.7", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "3.7") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, "90", 90.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "A") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPointsAssignmentExcusedWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.POINTS, null, 90.0, 100, excused = true) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Excused") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPercentageAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.PERCENT, "80%", 80.0, 100) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "B") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GRADES, TestCategory.INTERACTION, false) + fun testPassFailAssignmentWithQuantitativeRestriction() { + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade(score = 100.0, data = data, restrictQuantitativeData = true) + val assignment = addAssignment(data, Assignment.GradingType.PASS_FAIL, "complete", 0.0, 0) + + goToGrades(data) + + courseGradesPage.assertAssignmentDisplayed(assignment.name!!, "Complete") + } + + private fun setUpData( + courseCount: Int = 1, + invitedCourseCount: Int = 0, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0 + ): MockCanvas { + val data = MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + invitedCourseCount = invitedCourseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + + val course = data.courses.values.first() + + val gradesTab = Tab(position = 2, label = "Grades", visibility = "public", tabId = Tab.GRADES_ID) + data.courseTabs[course.id]!! += gradesTab + + return data + } + + private fun addAssignment(data: MockCanvas, gradingType: Assignment.GradingType, grade: String?, score: Double?, maxScore: Int, excused: Boolean = false): Assignment { + val course = data.courses.values.first() + val student = data.students.first() + + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType) ?: "", + pointsPossible = maxScore, + ) + + data.addSubmissionForAssignment(assignment.id, student.id, Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, grade = grade, score = score, excused = excused) + + return assignment + } + + private fun goToGrades(data: MockCanvas) { + val student = data.students[0] + val token = data.tokenFor(student)!! + val course = data.courses.values.first() + tokenLogin(data.domain, token, student) + dashboardPage.waitForRender() + dashboardPage.selectCourse(course) + courseBrowserPage.selectGrades() + } + + private fun setUpCustomGrade(grade: String? = null, score: Double? = null, data: MockCanvas, restrictQuantitativeData: Boolean) { + val student = data.students[0] + val course = data.courses.values.first() + + val enrollment = course.enrollments!!.first { it.userId == student.id } + .copy( + grades = Grades(currentGrade = grade, currentScore = score), + computedCurrentGrade = grade, + computedCurrentScore = score + ) + + val gradingScheme = listOf( + listOf("A", 0.9), + listOf("B", 0.8), + listOf("C", 0.7), + listOf("D", 0.6), + listOf("F", 0.0) + ) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + enrollments = mutableListOf(enrollment), + gradingSchemeRaw = gradingScheme) + data.courses[course.id] = newCourse + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt index 7ecf500d36..56568c7fa7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt @@ -21,6 +21,8 @@ import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAccountNotification import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Grades import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -39,7 +41,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testNavigateToDashboard() { // User should be able to tap and navigate to dashboard page - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) dashboardPage.clickInboxTab() inboxPage.goToDashboard() dashboardPage.assertDisplaysCourse(data.courses.values.first()) // disambiguates via isDisplayed() @@ -54,7 +57,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_emptyState() { // Empty state should be displayed with a 'Add Courses' button, when nothing is favorited (and courses are completed/concluded) // With the new DashboardCard api being used, if nothing is a favorite it will default to active enrollments - getToDashboard(courseCount = 0, pastCourseCount = 1) + val data = setUpData(courseCount = 0, pastCourseCount = 1) + goToDashboard(data) dashboardPage.assertDisplaysAddCourseMessage() } @@ -63,7 +67,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_addFavorite() { // Starring should add course to favorite list - val data = getToDashboard(courseCount = 2, favoriteCourseCount = 1) + val data = setUpData(courseCount = 2, favoriteCourseCount = 1) + goToDashboard(data) val nonFavorite = data.courses.values.filter { x -> !x.isFavorite }.first() dashboardPage.assertCourseNotShown(nonFavorite) @@ -84,7 +89,8 @@ class DashboardInteractionTest : StudentTest() { fun testDashboardCourses_removeFavorite() { // Un-starring should remove course from favorite list - val data = getToDashboard(courseCount = 2, favoriteCourseCount = 2) + val data = setUpData(courseCount = 2, favoriteCourseCount = 2) + goToDashboard(data) val favorite = data.courses.values.filter { x -> x.isFavorite }.first() dashboardPage.assertDisplaysCourse(favorite) @@ -105,7 +111,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardCourses_addAllToFavorites() { - val data = getToDashboard(courseCount = 3, favoriteCourseCount = 0) + val data = setUpData(courseCount = 3, favoriteCourseCount = 0) + goToDashboard(data) val toFavorite = data.courses.values data.courses.values.forEach { dashboardPage.assertDisplaysCourse(it) } @@ -123,7 +130,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardCourses_removeAllFromFavorites() { - val data = getToDashboard(courseCount = 3, favoriteCourseCount = 2) + val data = setUpData(courseCount = 3, favoriteCourseCount = 2) + goToDashboard(data) val toRemove = data.courses.values.filter { it.isFavorite } toRemove.forEach { dashboardPage.assertDisplaysCourse(it) } @@ -142,7 +150,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_refresh() { // Pull to refresh loads new announcements - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) // No announcements initially + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) // No announcements initially + goToDashboard(data) dashboardPage.assertAnnouncementsGone() val announcement = data.addAccountNotification() dashboardPage.refresh() @@ -153,7 +162,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_dismiss() { // Tapping dismiss should remove the announcement. Refresh should not display it again. - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + goToDashboard(data) val announcement = data.accountNotifications.values.first() dashboardPage.assertAnnouncementShowing(announcement) @@ -166,7 +176,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardInvite_accept() { - val data = getToDashboard(courseCount = 2, invitedCourseCount = 1) + val data = setUpData(courseCount = 2, invitedCourseCount = 1) + goToDashboard(data) val invitedCourse = data.courses.values.first { it.enrollments?.any { it.enrollmentState == EnrollmentAPI.STATE_INVITED } ?: false } dashboardPage.assertInviteShowing(invitedCourse.name) @@ -182,7 +193,8 @@ class DashboardInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardInvite_decline() { - val data = getToDashboard(courseCount = 2, invitedCourseCount = 1) + val data = setUpData(courseCount = 2, invitedCourseCount = 1) + goToDashboard(data) val invitedCourse = data.courses.values.first { it.enrollments?.any { it.enrollmentState == EnrollmentAPI.STATE_INVITED } ?: false } dashboardPage.assertInviteShowing(invitedCourse.name) @@ -199,7 +211,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION) fun testDashboardAnnouncement_view() { // Tapping global announcement displays the content - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1, announcementCount = 1) + goToDashboard(data) val announcement = data.accountNotifications.values.first() dashboardPage.assertAnnouncementShowing(announcement) @@ -213,7 +226,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_tappingCourseCardDisplaysCourseBrowser() { // Tapping on a course card opens course browser page - val data = getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) val course = data.courses.values.first() dashboardPage.selectCourse(course) @@ -230,7 +244,8 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_gradeIsDisplayedWhenShowGradesIsSelected() { // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected - getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) leftSideNavigationDrawerPage.setShowGrades(true) dashboardPage.assertShowsGrades() } @@ -239,29 +254,67 @@ class DashboardInteractionTest : StudentTest() { @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) fun testDashboardCourses_gradeIsNotDisplayedWhenShowGradesIsDeSelected() { // [Student] Grade is NOT displayed when 'Show Grades' (located in navigation drawer) is de-selected - getToDashboard(courseCount = 1, favoriteCourseCount = 1) + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + goToDashboard(data) leftSideNavigationDrawerPage.setShowGrades(false) dashboardPage.assertHidesGrades() } - private fun getToDashboard( - courseCount: Int = 1, - invitedCourseCount: Int = 0, - pastCourseCount: Int = 0, - favoriteCourseCount: Int = 0, - announcementCount: Int = 0 + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) + fun testDashboardCourses_gradeIsDisplayedWithGradeAndScoreWhenNotRestricted() { + // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, false) + goToDashboard(data) + leftSideNavigationDrawerPage.setShowGrades(true) + dashboardPage.assertGradeText("A 100%") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.DASHBOARD, TestCategory.INTERACTION, false) + fun testDashboardCourses_gradeIsDisplayedWithGradeOnlyWhenQuantitativeDataIsRestricted() { + // [Student] Grade is displayed when 'Show Grades' (located in navigation drawer) is selected + val data = setUpData(courseCount = 1, favoriteCourseCount = 1) + setUpCustomGrade("A", 100.0, data, true) + goToDashboard(data) + leftSideNavigationDrawerPage.setShowGrades(true) + dashboardPage.assertGradeText("A") + } + + private fun setUpData( + courseCount: Int = 1, + invitedCourseCount: Int = 0, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0 ): MockCanvas { - val data = MockCanvas.init( - studentCount = 1, - courseCount = courseCount, - invitedCourseCount = invitedCourseCount, - pastCourseCount = pastCourseCount, - favoriteCourseCount = favoriteCourseCount, - accountNotificationCount = announcementCount) + return MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + invitedCourseCount = invitedCourseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + } + + private fun goToDashboard(data: MockCanvas) { val student = data.students[0] val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) dashboardPage.waitForRender() - return data + } + + private fun setUpCustomGrade(grade: String, score: Double, data: MockCanvas, restrictQuantitativeData: Boolean) { + val student = data.students[0] + val course = data.courses.values.first() + + val enrollment = course.enrollments!!.first { it.userId == student.id } + .copy(grades = Grades(currentGrade = grade, currentScore = score)) + + val newCourse = course + .copy(settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + enrollments = mutableListOf(enrollment)) + data.courses[course.id] = newCourse } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt index d40f45ac7c..394cab7b96 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DiscussionsInteractionTest.kt @@ -70,7 +70,7 @@ class DiscussionsInteractionTest : StudentTest() { // Let's attach an html attachment after the fact val attachmentHtml = - """ + """ @@ -94,8 +94,8 @@ class DiscussionsInteractionTest : StudentTest() { discussionDetailsPage.assertDescriptionText(topicDescription) discussionDetailsPage.assertMainAttachmentDisplayed() discussionDetailsPage.previewAndCheckMainAttachment( - WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "-- Socrates") + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "-- Socrates") ) } @@ -126,10 +126,10 @@ class DiscussionsInteractionTest : StudentTest() { val topicName = "Discussion with link in description" data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = topicName, - topicDescription = course2Html + course = course1, + user = user1, + topicTitle = topicName, + topicDescription = course2Html ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -151,16 +151,16 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "I'm unread (at first)" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage ) // Bring up discussion page @@ -187,7 +187,7 @@ class DiscussionsInteractionTest : StudentTest() { val course = data.courses.values.first() val attachmentHtml = - """ + """ @@ -201,12 +201,12 @@ class DiscussionsInteractionTest : StudentTest() { """ val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = data.users.values.first(), - topicTitle = "Awesome topic", - topicDescription = "With an attachment!" + course = course, + user = data.users.values.first(), + topicTitle = "Awesome topic", + topicDescription = "With an attachment!" ) - val attachment = createHtmlAttachment(data,attachmentHtml) + val attachment = createHtmlAttachment(data, attachmentHtml) topicHeader.attachments = mutableListOf(attachment) courseBrowserPage.selectDiscussions() @@ -214,8 +214,8 @@ class DiscussionsInteractionTest : StudentTest() { discussionDetailsPage.assertTopicInfoShowing(topicHeader) discussionDetailsPage.assertMainAttachmentDisplayed() discussionDetailsPage.previewAndCheckMainAttachment( - WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "No matter where you go") + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "No matter where you go") ) } @@ -233,15 +233,15 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "Like me!" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage ) // Bring up discussion page @@ -273,18 +273,18 @@ class DiscussionsInteractionTest : StudentTest() { val replyMessage = "A grader liked me!" val topicHeader = data.addDiscussionTopicToCourse( - course = course, - user = user, - topicTitle = topicName, - topicDescription = topicDescription, - allowRating = true, - onlyGradersCanRate = true + course = course, + user = user, + topicTitle = topicName, + topicDescription = topicDescription, + allowRating = true, + onlyGradersCanRate = true ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user, - replyMessage = replyMessage, - ratingSum = 1 + topicHeader = topicHeader, + user = user, + replyMessage = replyMessage, + ratingSum = 1 ) // Bring up discussion page @@ -308,16 +308,16 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with unlikable posts", - topicDescription = "unlikable discussion", - allowRating = false + course = course1, + user = user1, + topicTitle = "Discussion with unlikable posts", + topicDescription = "unlikable discussion", + allowRating = false ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user1, - replyMessage = "You can't touch this!" + topicHeader = topicHeader, + user = user1, + replyMessage = "You can't touch this!" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -335,10 +335,10 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion view base", - topicDescription = "A viewed discussion" + course = course1, + user = user1, + topicTitle = "Discussion view base", + topicDescription = "A viewed discussion" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -355,15 +355,15 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies", - topicDescription = "Reply-o-rama" + course = course1, + user = user1, + topicTitle = "Discussion with replies", + topicDescription = "Reply-o-rama" ) val discussionEntry = data.addReplyToDiscussion( - topicHeader = topicHeader, - user = user1, - replyMessage = "Replied" + topicHeader = topicHeader, + user = user1, + replyMessage = "Replied" ) courseBrowserPage.selectDiscussions() @@ -383,11 +383,11 @@ class DiscussionsInteractionTest : StudentTest() { val user1 = data.users.values.first() data.discussionRepliesEnabled = false // Do we still need these? val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies disabled", - topicDescription = "Replies disabled", - allowReplies = false + course = course1, + user = user1, + topicTitle = "Discussion with replies disabled", + topicDescription = "Replies disabled", + allowReplies = false ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -403,10 +403,10 @@ class DiscussionsInteractionTest : StudentTest() { val course1 = data.courses.values.first() val user1 = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion with replies enabled", - topicDescription = "Replies enabled" + course = course1, + user = user1, + topicTitle = "Discussion with replies enabled", + topicDescription = "Replies enabled" ) courseBrowserPage.selectDiscussions() discussionListPage.pullToUpdate() @@ -432,10 +432,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Hey! A Discussion!", - topicDescription = "Awesome!" + course = course1, + user = user, + topicTitle = "Hey! A Discussion!", + topicDescription = "Awesome!" ) courseBrowserPage.selectDiscussions() @@ -449,7 +449,7 @@ class DiscussionsInteractionTest : StudentTest() { // to manually attach anything via Espresso, since it would require manipulating // system UIs. val attachmentHtml = - """ + """ @@ -470,9 +470,11 @@ class DiscussionsInteractionTest : StudentTest() { Thread.sleep(3000) //allow some time to the reply to propagate discussionDetailsPage.assertReplyDisplayed(discussionEntry) discussionDetailsPage.assertReplyAttachment(discussionEntry) - discussionDetailsPage.previewAndCheckReplyAttachment(discussionEntry, - WebViewTextCheck(Locator.ID,"header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "That's one small step")) + discussionDetailsPage.previewAndCheckReplyAttachment( + discussionEntry, + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "That's one small step") + ) } // Tests that we can make a threaded reply to a reply @@ -484,10 +486,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Wow! A Discussion!", - topicDescription = "Cool!" + course = course1, + user = user, + topicTitle = "Wow! A Discussion!", + topicDescription = "Cool!" ) courseBrowserPage.selectDiscussions() @@ -503,7 +505,7 @@ class DiscussionsInteractionTest : StudentTest() { // Now let's reply to the reply (i.e., threaded reply) val replyReplyText = "Threaded Reply" - discussionDetailsPage.replyToReply(replyEntry,replyReplyText) + discussionDetailsPage.replyToReply(replyEntry, replyReplyText) // And verify that our reply-to-reply is showing val replyReplyEntry = findDiscussionEntry(data, topicHeader.title!!, replyReplyText) @@ -521,10 +523,10 @@ class DiscussionsInteractionTest : StudentTest() { val user = data.users.values.first() val topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user, - topicTitle = "Discussion threaded reply attachment", - topicDescription = "Cool!" + course = course1, + user = user, + topicTitle = "Discussion threaded reply attachment", + topicDescription = "Cool!" ) courseBrowserPage.selectDiscussions() @@ -540,14 +542,14 @@ class DiscussionsInteractionTest : StudentTest() { // Now let's reply to the reply (i.e., threaded reply) val replyReplyText = "Threaded Reply" - discussionDetailsPage.replyToReply(replyEntry,replyReplyText) + discussionDetailsPage.replyToReply(replyEntry, replyReplyText) // And verify that our reply-to-reply is showing val replyReplyEntry = findDiscussionEntry(data, topicHeader.title!!, replyReplyText) // Lets attach an html attachment behind the scenes val attachmentHtml = - """ + """ @@ -560,16 +562,18 @@ class DiscussionsInteractionTest : StudentTest() { """ - val attachment = createHtmlAttachment(data,attachmentHtml) + val attachment = createHtmlAttachment(data, attachmentHtml) replyReplyEntry.attachments = mutableListOf(attachment) discussionDetailsPage.refresh() // To pick up updated discussion reply Thread.sleep(3000) //Need this because somehow sometimes refresh does "double-refresh" and assert is failing below. discussionDetailsPage.assertReplyDisplayed(replyReplyEntry) discussionDetailsPage.assertReplyAttachment(replyReplyEntry) - discussionDetailsPage.previewAndCheckReplyAttachment(replyReplyEntry, - WebViewTextCheck(Locator.ID,"header1", "Famous Quote"), - WebViewTextCheck(Locator.ID, "p1", "The only thing we have to fear")) + discussionDetailsPage.previewAndCheckReplyAttachment( + replyReplyEntry, + WebViewTextCheck(Locator.ID, "header1", "Famous Quote"), + WebViewTextCheck(Locator.ID, "p1", "The only thing we have to fear") + ) } @@ -590,19 +594,63 @@ class DiscussionsInteractionTest : StudentTest() { // Add an assignment val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - name = assignmentName, - pointsPossible = 12 + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 + ) + + // Now create a discussion associated with the assignment + val discussion = data.addDiscussionTopicToCourse( + course = course, + user = teacher, + assignment = assignment + ) + + // Sign in + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + // Navigate to discussions + dashboardPage.selectCourse(course) + courseBrowserPage.selectDiscussions() + discussionListPage.selectTopic(discussion.title!!) + discussionDetailsPage.assertPointsPossibleDisplayed(assignment.pointsPossible.toInt().toString()) + } + + // Tests a discussion with a linked assignment, show possible points if not restricted + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DISCUSSIONS, TestCategory.INTERACTION, false) + fun testDiscussion_showPointsIfNotRestricted() { + val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) + + val course = data.courses.values.first() + val student = data.students[0] + val teacher = data.teachers[0] + val assignmentName = "Assignment up for discussion" + + // Make sure we have a discussions tab + val discussionsTab = Tab(position = 2, label = "Discussions", visibility = "public", tabId = Tab.DISCUSSIONS_ID) + data.courseTabs[course.id]!! += discussionsTab + + // Add an assignment + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 ) // Now create a discussion associated with the assignment val discussion = data.addDiscussionTopicToCourse( - course = course, - user = teacher, - assignment = assignment + course = course, + user = teacher, + assignment = assignment ) + // Setup course settings + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = false) + // Sign in val token = data.tokenFor(student)!! tokenLogin(data.domain, token, student) @@ -615,12 +663,56 @@ class DiscussionsInteractionTest : StudentTest() { } + // Tests a discussion with a linked assignment, hide possible points if restricted + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DISCUSSIONS, TestCategory.INTERACTION, false) + fun testDiscussion_hidePointsIfRestricted() { + val data = MockCanvas.init(teacherCount = 1, studentCount = 1, courseCount = 1, favoriteCourseCount = 1) + + val course = data.courses.values.first() + val student = data.students[0] + val teacher = data.teachers[0] + val assignmentName = "Assignment up for discussion" + + // Make sure we have a discussions tab + val discussionsTab = Tab(position = 2, label = "Discussions", visibility = "public", tabId = Tab.DISCUSSIONS_ID) + data.courseTabs[course.id]!! += discussionsTab + + // Add an assignment + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + name = assignmentName, + pointsPossible = 12 + ) + + // Now create a discussion associated with the assignment + val discussion = data.addDiscussionTopicToCourse( + course = course, + user = teacher, + assignment = assignment + ) + + // Setup course settings + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = true) + + // Sign in + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + // Navigate to discussions + dashboardPage.selectCourse(course) + courseBrowserPage.selectDiscussions() + discussionListPage.selectTopic(discussion.title!!) + discussionDetailsPage.assertPointsPossibleNotDisplayed() + } + // // Utilities // // Needed to grab the discussion entry associated with a manual discussion reply - private fun findDiscussionEntry(data: MockCanvas, topicName: String, replyMessage: String) : DiscussionEntry { + private fun findDiscussionEntry(data: MockCanvas, topicName: String, replyMessage: String): DiscussionEntry { // Gotta grab our reply message... val myCourse = data.courses.values.first() val topicHeader = data.courseDiscussionTopicHeaders[myCourse.id]?.find { it.title.equals(topicName) } @@ -628,11 +720,11 @@ class DiscussionsInteractionTest : StudentTest() { val topic = data.discussionTopics[topicHeader!!.id] assertNotNull("Can't find topic", topic) var discussionEntry = topic!!.views.find { it.message.equals(replyMessage) } - if(discussionEntry == null) { + if (discussionEntry == null) { // It might be a threaded reply topic.views.forEach { view -> view.replies?.forEach { reply -> - if(reply.message.equals(replyMessage)) { + if (reply.message.equals(replyMessage)) { return reply } } @@ -645,13 +737,15 @@ class DiscussionsInteractionTest : StudentTest() { // Mock a specified number of students and courses, and navigate to the first course private fun getToCourse( - studentCount: Int = 1, - courseCount: Int = 1, - enableDiscussionTopicCreation: Boolean = true): MockCanvas { + studentCount: Int = 1, + courseCount: Int = 1, + enableDiscussionTopicCreation: Boolean = true + ): MockCanvas { val data = MockCanvas.init( - studentCount = studentCount, - courseCount = courseCount, - favoriteCourseCount = courseCount) + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = courseCount + ) if (enableDiscussionTopicCreation) { data.courses.values.forEach { course -> @@ -677,19 +771,19 @@ class DiscussionsInteractionTest : StudentTest() { fun createHtmlAttachment(data: MockCanvas, html: String): RemoteFile { val course1 = data.courses.values.first() val fileId = data.addFileToCourse( - courseId = course1.id, - displayName = "page.html", - contentType = "text/html", - fileContent = html + courseId = course1.id, + displayName = "page.html", + contentType = "text/html", + fileContent = html ) val attachment = RemoteFile( - id = fileId, - displayName = "page.html", - fileName = "page.html", - contentType = "text/html", - url = "https://mock-data.instructure.com/files/$fileId/preview", - size = html.length.toLong() + id = fileId, + displayName = "page.html", + fileName = "page.html", + contentType = "text/html", + url = "https://mock-data.instructure.com/files/$fileId/preview", + size = html.length.toLong() ) return attachment diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt similarity index 80% rename from apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt rename to apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt index 69294fd566..122ec49631 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GradesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ElementaryGradesInteractionTest.kt @@ -20,6 +20,7 @@ import androidx.test.espresso.Espresso import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addCourseWithEnrollment import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Enrollment import com.instructure.espresso.page.getStringFromResource import com.instructure.panda_annotations.FeatureCategory @@ -34,7 +35,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @HiltAndroidTest -class GradesInteractionTest : StudentTest() { +class ElementaryGradesInteractionTest : StudentTest() { override fun displaysPageObjects() = Unit @@ -134,6 +135,49 @@ class GradesInteractionTest : StudentTest() { gradesPage.assertCourseShownWithGrades(notGradedCourse.name, "0%") } + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + fun testDontShowProgressWhenQuantitativeDataIsRestricted() { + val data = createMockData(courseCount = 1) + goToGradesTab(data) + + gradesPage.assertPageObjects() + + var course = data.addCourseWithEnrollment( + data.students[0], + Enrollment.EnrollmentType.Student, + 50.0, + "C+", + restrictQuantitativeData = true + ) + + gradesPage.refresh() + + gradesPage.assertCourseShownWithGrades(course.name, "C+") + gradesPage.assertProgressNotDisplayed(course.name) + } + + @Test + @TestMetaData(Priority.COMMON, FeatureCategory.K5_DASHBOARD, TestCategory.INTERACTION) + fun testDontShowGradeWhenQuantitativeDataIsRestrictedAndThereIsOnlyScore() { + val data = createMockData(courseCount = 1) + goToGradesTab(data) + + gradesPage.assertPageObjects() + + var course = data.addCourseWithEnrollment( + data.students[0], + Enrollment.EnrollmentType.Student, + 50.0, + "", + restrictQuantitativeData = true + ) + + gradesPage.refresh() + + gradesPage.assertCourseShownWithGrades(course.name, "--") + } + private fun createMockData( courseCount: Int = 0, withGradingPeriods: Boolean = false, diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt index c4a9340031..aa1c84ccdf 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/GroupLinksInteractionTest.kt @@ -25,6 +25,7 @@ import com.instructure.canvas.espresso.mockCanvas.addFolderToCourse import com.instructure.canvas.espresso.mockCanvas.addGroupToCourse import com.instructure.canvas.espresso.mockCanvas.addPageToCourse import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvas.espresso.refresh import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Group @@ -61,7 +62,7 @@ class GroupLinksInteractionTest : StudentTest() { fun testGroupLink_base() { setUpGroupAndSignIn() dashboardPage.selectGroup(group) - courseBrowserPage.assertTitleCorrect(group) + groupBrowserPage.assertTitleCorrect(group) } // Link to groups opens dashboard - eg: "/groups" @@ -72,6 +73,38 @@ class GroupLinksInteractionTest : StudentTest() { dashboardPage.assertDisplaysGroup(group, course) } + // Test not favorite group on dashboard + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_DASHBOARD) + fun testGroupLink_dashboard_favoriteLogics() { + val data = setUpGroupAndSignIn() + val user = data.users.values.first() + val nonFavoriteGroup = data.addGroupToCourse( + course = course, + members = listOf(user), + isFavorite = false + ) + refresh() //Need to refresh because when we navigated to Dashboard page the nonFavoriteGroup was not existed yet. (However it won't be displayed because it's not favorite) + dashboardPage.assertGroupNotDisplayed(nonFavoriteGroup) + dashboardPage.assertDisplaysGroup(group, course) + } + + // Test that if no groups has selected as favorite then we display all groups + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_DASHBOARD) + fun testGroupLink_dashboard_not_selected_displays_all() { + val data = setUpGroupAndSignIn(isFavorite = false) + val user = data.users.values.first() + val group2 = data.addGroupToCourse( + course = course, + members = listOf(user), + isFavorite = false + ) + refresh() //Need to refresh because when we navigated to Dashboard page the group2 was not existed yet. + dashboardPage.assertDisplaysGroup(group, course) + dashboardPage.assertDisplaysGroup(group2, course) + } + // Link to file preview opens file - eg: "/groups/:id/files/folder/:id?preview=:id" @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.GROUPS, TestCategory.INTERACTION, false, SecondaryFeatureCategory.GROUPS_FILES) @@ -196,7 +229,7 @@ class GroupLinksInteractionTest : StudentTest() { // Mock a single student and course, mock a group and a number of items associated with the group, // sign in, then navigate to the dashboard. - private fun setUpGroupAndSignIn(): MockCanvas { + private fun setUpGroupAndSignIn(isFavorite: Boolean = true): MockCanvas { // Basic info val data = MockCanvas.init( @@ -211,7 +244,7 @@ class GroupLinksInteractionTest : StudentTest() { group = data.addGroupToCourse( course = course, members = listOf(user), - isFavorite = true + isFavorite = isFavorite ) // Add a discussion diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt index 46b1f194b4..91cd8158c0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ImportantDatesInteractionTest.kt @@ -17,7 +17,11 @@ package com.instructure.student.ui.interaction import com.instructure.canvas.espresso.StubTablet -import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addAssignmentCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.addCourseCalendarEvent +import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow @@ -49,7 +53,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(event.title!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(event.startDate)) } @@ -65,7 +69,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(assignment.name!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(assignmentScheduleItem.startDate)) } @@ -91,13 +95,13 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) val eventToCheck = data.addCourseCalendarEvent(course.id, 2.days.fromNow.iso8601, "Important event 2", "Important event 2 description", true) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) importantDatesPage.assertDayTextIsDisplayed(generateDayString(existedEventBeforeRefresh.startDate)) //Refresh the page and verify if the previously not displayed event will be displayed after the refresh. importantDatesPage.pullToRefresh() importantDatesPage.assertItemDisplayed(eventToCheck.title!!) - importantDatesPage.assertRecyclerViewItemCount(3) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(2) importantDatesPage.assertDayTextIsDisplayed(generateDayString(eventToCheck.startDate)) } @@ -112,7 +116,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(event.title!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) //Opening the calendar event importantDatesPage.clickImportantDatesItem(event.title!!) @@ -133,7 +137,7 @@ class ImportantDatesInteractionTest : StudentTest() { goToImportantDatesTab(data) importantDatesPage.assertItemDisplayed(assignment.name!!) - importantDatesPage.assertRecyclerViewItemCount(2) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(1) // We count both day texts and calendar events here, since both types are part of the recyclerView. //Opening the calendar assignment event importantDatesPage.clickImportantDatesItem(assignment.name!!) @@ -161,7 +165,7 @@ class ImportantDatesInteractionTest : StudentTest() { importantDatesPage.assertItemDisplayed(it.title!!) } } - importantDatesPage.assertRecyclerViewItemCount(3) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(2) importantDatesPage.assertDayTextIsDisplayed(generateDayString(calendarEvent.startDate)) } @@ -194,7 +198,7 @@ class ImportantDatesInteractionTest : StudentTest() { importantDatesPage.assertDayTextIsDisplayed(generateDayString(twoDaysFromNowEvent.startDate)) importantDatesPage.swipeUp() // Need to do this because on landscape mode the last item cannot be seen on the view by default. importantDatesPage.assertDayTextIsDisplayed(generateDayString(threeDaysFromNowEvent.startDate)) - importantDatesPage.assertRecyclerViewItemCount(6) // We count both day texts and calendar events here, since both types are part of the recyclerView. + importantDatesPage.assertRecyclerViewItemCount(3) } private fun goToImportantDatesTab(data: MockCanvas) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt index f9d0bd8da6..98af62da5a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/InAppUpdateInteractionTest.kt @@ -23,6 +23,7 @@ import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import com.google.android.play.core.appupdate.testing.FakeAppUpdateManager +import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.StubTablet import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.init @@ -269,7 +270,7 @@ class InAppUpdateInteractionTest : StudentTest() { } @Test - @StubTablet("Fails on Nexus 7 API level 26, phone version works correctly") + @Stub("Unstable, there is a ticket to fix this") fun flexibleUpdateCompletesIfAppRestarts() { with(appUpdateManager) { setUpdateAvailable(400) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt index 6d3bef69c2..20c99a602c 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ModuleInteractionTest.kt @@ -18,12 +18,36 @@ package com.instructure.student.ui.interaction import android.text.Html import androidx.test.espresso.Espresso import androidx.test.espresso.web.webdriver.Locator -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.* +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addAssignment +import com.instructure.canvas.espresso.mockCanvas.addDiscussionTopicToCourse +import com.instructure.canvas.espresso.mockCanvas.addFileToCourse +import com.instructure.canvas.espresso.mockCanvas.addItemToModule +import com.instructure.canvas.espresso.mockCanvas.addLTITool +import com.instructure.canvas.espresso.mockCanvas.addModuleToCourse +import com.instructure.canvas.espresso.mockCanvas.addPageToCourse +import com.instructure.canvas.espresso.mockCanvas.addQuestionToQuiz +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.LockInfo +import com.instructure.canvasapi2.models.LockedModule +import com.instructure.canvasapi2.models.ModuleContentDetails +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.models.Quiz +import com.instructure.canvasapi2.models.QuizAnswer +import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.* +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData import com.instructure.student.R import com.instructure.student.ui.pages.WebViewTextCheck import com.instructure.student.ui.utils.StudentTest @@ -43,7 +67,6 @@ class ModuleInteractionTest : StudentTest() { private var page: Page? = null private val fileName = "ModuleFile.html" private var fileCheck: WebViewTextCheck? = null - private val externalUrl = "https://www.google.com" private var quiz: Quiz? = null // Tapping an Assignment module item should navigate to that item's detail page @@ -55,7 +78,19 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + // Verify that we can launch into the assignment from an assignment module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, assignment!!.name!!) modulesPage.clickModuleItem(module, assignment!!.name!!) @@ -71,8 +106,23 @@ class ModuleInteractionTest : StudentTest() { val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + val user = data.users.values.first() + + // Create a discussion and add it as a module item + topicHeader = data.addDiscussionTopicToCourse( + course = course1, + user = user, + topicTitle = "Discussion in module", + topicDescription = "In. A. Module." + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = topicHeader!! + ) // Verify that we can launch into a discussion from a discussion module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, topicHeader!!.title!!) modulesPage.clickModuleItem(module, topicHeader!!.title!!) @@ -87,6 +137,14 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = ltiTool!! + ) + + modulesPage.refresh() modulesPage.clickModuleItem(module, "Google Drive") canvasWebViewPage.assertTitle("Google Drive") } @@ -96,12 +154,21 @@ class ModuleInteractionTest : StudentTest() { @TestMetaData(Priority.MANDATORY, FeatureCategory.MODULES, TestCategory.INTERACTION, false) fun testModules_launchesIntoExternalURL() { // Basic mock setup + val externalUrl = "https://www.google.com" val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create an external URL and add it as a module item + data.addItemToModule( + course = course1, + moduleId = module.id, + item = externalUrl + ) + // click the external url module item - modulesPage.clickModuleItem(module,externalUrl) + modulesPage.refresh() + modulesPage.clickModuleItem(module, externalUrl) // Not much we can test here, as it is an external URL, but testModules_navigateToNextAndPreviousModuleItems // will test that the module name and module item name are displayed correctly. canvasWebViewPage.checkWebViewURL("https://www.google.com") @@ -116,8 +183,27 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a file and add it as a module item + val fileContent = "

A Heading

" + fileCheck = WebViewTextCheck(Locator.ID, "heading1", "A Heading") + + val fileId = data.addFileToCourse( + courseId = course1.id, + displayName = fileName, + fileContent = fileContent, + contentType = "text/html" + ) + val rootFolderId = data.courseRootFolders[course1.id]!!.id + val fileFolder = data.folderFiles[rootFolderId]?.find { it.id == fileId } + data.addItemToModule( + course = course1, + moduleId = module.id, + item = fileFolder!! + ) + // Click the file module and verify that the file appears - modulesPage.clickModuleItem(module,fileName, R.id.openButton) + modulesPage.refresh() + modulesPage.clickModuleItem(module, fileName, R.id.openButton) canvasWebViewPage.waitForWebView() canvasWebViewPage.runTextChecks(fileCheck!!) } @@ -131,7 +217,22 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a page and add it as a module item + page = data.addPageToCourse( + courseId = course1.id, + pageId = data.newItemId(), + published = true, + title = "Page In Course", + url = URLEncoder.encode("Page In Course", "UTF-8") + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = page!! + ) + // Verify that we can launch into a page from a page module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, page!!.title!!) modulesPage.clickModuleItem(module, page!!.title!!) @@ -141,9 +242,9 @@ class ModuleInteractionTest : StudentTest() { // Also, just use the first 10 chars because you risk encountering multiple-newlines // (which show as single newlines in webview, or even no-newlines if at the end // of the string) if you go much longer - var expectedBody = Html.fromHtml(page!!.body!!).toString().substring(0,10) + var expectedBody = Html.fromHtml(page!!.body!!).toString().substring(0, 10) canvasWebViewPage.runTextChecks( - WebViewTextCheck(Locator.ID, "content", expectedBody) + WebViewTextCheck(Locator.ID, "content", expectedBody) ) } @@ -157,7 +258,48 @@ class ModuleInteractionTest : StudentTest() { val course1 = data.courses.values.first() val module = data.courseModules[course1.id]!!.first() + // Create a quiz and add it as a module item + quiz = data.addQuizToCourse( + course = course1 + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 1", + questionText = "What is 2 + 5?", + questionType = "multiple_choice_question", + answers = arrayOf( + QuizAnswer(answerText = "7"), + QuizAnswer(answerText = "25"), + QuizAnswer(answerText = "-7") + ) + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 2", + questionText = "Pi is greater than the square root of 2", + questionType = "true_false_question" + ) + + data.addQuestionToQuiz( + course = course1, + quizId = quiz!!.id, + questionName = "Math 3", + questionText = "Write an essay on why math is so awesome", + questionType = "essay_question" + ) + + data.addItemToModule( + course = course1, + moduleId = module.id, + item = quiz!! + ) + // Verify that we can launch into a quiz from a quiz module item + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) modulesPage.assertModuleItemDisplayed(module, quiz!!.title!!) /* TODO: Check that the quiz is displayed if/when we can do so via WebView @@ -177,7 +319,20 @@ class ModuleInteractionTest : StudentTest() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) val course1 = data.courses.values.first() - val module = data.courseModules[course1.id]!!.first() + var module = data.courseModules[course1.id]!!.first() + + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + + module = data.courseModules[course1.id]!!.first() val firstModuleItem = module.items[0] // Verify that expanding a module shows the module items and collapsing a module @@ -185,12 +340,13 @@ class ModuleInteractionTest : StudentTest() { // We're going on the assumption that the lone module is initially expanded. Although // the initial assertModuleItemDisplayed() would expand the module if it was not expanded // already. + modulesPage.refresh() modulesPage.assertModuleDisplayed(module) - modulesPage.assertModuleItemDisplayed(module,firstModuleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, firstModuleItem.title!!) modulesPage.clickModule(module) modulesPage.assertModuleItemNotDisplayed(firstModuleItem.title!!) modulesPage.clickModule(module) - modulesPage.assertModuleItemDisplayed(module,firstModuleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, firstModuleItem.title!!) } @@ -206,10 +362,10 @@ class ModuleInteractionTest : StudentTest() { // For each module item, go into the module detail page, click the back button, // and verify that we've returned to the module list page. - for(moduleItem in module.items) { - modulesPage.clickModuleItem(module,moduleItem.title!!) + for (moduleItem in module.items) { + modulesPage.clickModuleItem(module, moduleItem.title!!) Espresso.pressBack() - modulesPage.assertModuleItemDisplayed(module,moduleItem.title!!) + modulesPage.assertModuleItemDisplayed(module, moduleItem.title!!) } } @@ -221,30 +377,85 @@ class ModuleInteractionTest : StudentTest() { fun testModules_navigateToNextAndPreviousModuleItems() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) + val externalUrl = "https://www.google.com" val course1 = data.courses.values.first() - val module = data.courseModules[course1.id]!!.first() + var module = data.courseModules[course1.id]!!.first() + val user = data.users.values.first() + + // Create an assignment and add it as a module item + assignment = data.addAssignment( + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = assignment!! + ) + + // Create a discussion and add it as a module item + topicHeader = data.addDiscussionTopicToCourse( + course = course1, + user = user, + topicTitle = "Discussion in module", + topicDescription = "In. A. Module." + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = topicHeader!! + ) + + val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = ltiTool!! + ) + + // Create a page and add it as a module item + page = data.addPageToCourse( + courseId = course1.id, + pageId = data.newItemId(), + published = true, + title = "Page In Course", + url = URLEncoder.encode("Page In Course", "UTF-8") + ) + data.addItemToModule( + course = course1, + moduleId = module.id, + item = page!! + ) + + // Create an external URL and add it as a module item + data.addItemToModule( + course = course1, + moduleId = module.id, + item = externalUrl + ) + + module = data.courseModules[course1.id]!!.first() // Iterate through the module items, starting at the first val moduleItemList = module.items - modulesPage.clickModuleItem(module,moduleItemList[0].title!!) + modulesPage.refresh() + modulesPage.clickModuleItem(module, moduleItemList[0].title!!) var moduleIndex = 0; // we start here - while(moduleIndex < moduleItemList.count()) { + while (moduleIndex < moduleItemList.count()) { val moduleItem = moduleItemList[moduleIndex] // Make sure that the previous button is appropriately displayed/gone - if(moduleIndex == 0) { + if (moduleIndex == 0) { moduleProgressionPage.assertPreviousButtonInvisible() - } - else { + } else { moduleProgressionPage.assertPreviousButtonDisplayed() } // Make sure that the next button is appropriately displayed/gone - if(moduleIndex == moduleItemList.count() - 1) { + if (moduleIndex == moduleItemList.count() - 1) { moduleProgressionPage.assertNextButtonInvisible() - } - else { + } else { moduleProgressionPage.assertNextButtonDisplayed() } @@ -256,12 +467,12 @@ class ModuleInteractionTest : StudentTest() { // Let's navigate to our next page moduleIndex += 1 - if(moduleIndex < moduleItemList.count()) { + if (moduleIndex < moduleItemList.count()) { moduleProgressionPage.clickNextButton() } } - if(moduleItemList.count() > 1) { + if (moduleItemList.count() > 1) { // Let's make sure that the "previous" button works as well. moduleProgressionPage.clickPreviousButton() val moduleItem = moduleItemList[moduleItemList.count() - 2] @@ -272,6 +483,7 @@ class ModuleInteractionTest : StudentTest() { // Module can't be accessed unless all prerequisites have been fulfilled @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_moduleLockedWithUnfulfilledPrerequisite() { // Basic mock setup val data = getToCourseModules(studentCount = 1, courseCount = 1) @@ -280,26 +492,27 @@ class ModuleInteractionTest : StudentTest() { // Let's add a second module that has the first one as a prerequisite val module2 = data.addModuleToCourse( - course = course1, - moduleName = "Prereq Module", - prerequisiteIds = longArrayOf(module.id), - state = ModuleObject.State.Locked.toString() + course = course1, + moduleName = "Prereq Module", + prerequisiteIds = longArrayOf(module.id), + state = ModuleObject.State.Locked.toString() ) // And let's add an assignment to the new module var unavailableAssignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, - // Man, this is a bit hokey, but it's what I had to do to get the assignment to show - // up as unavailable in the assignment details page - lockInfo = LockInfo( - modulePrerequisiteNames = arrayListOf(module.name!!), - contextModule = LockedModule(name = module.name!!) ) + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + // Man, this is a bit hokey, but it's what I had to do to get the assignment to show + // up as unavailable in the assignment details page + lockInfo = LockInfo( + modulePrerequisiteNames = arrayListOf(module.name!!), + contextModule = LockedModule(name = module.name!!) + ) ) data.addItemToModule( - course = course1, - moduleId = module2.id, - item = unavailableAssignment + course = course1, + moduleId = module2.id, + item = unavailableAssignment ) // Refresh to get module list update, select module2, and assert that unavailableAssignment is locked @@ -320,20 +533,20 @@ class ModuleInteractionTest : StudentTest() { // Let's add a second module with a lockUntil setting val module2 = data.addModuleToCourse( - course = course1, - moduleName = "Locked Module", - unlockAt = 2.days.fromNow.iso8601 + course = course1, + moduleName = "Locked Module", + unlockAt = 2.days.fromNow.iso8601 ) // And let's create an assignment and add it to the "locked" module. val lockedAssignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY + courseId = course1.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY ) data.addItemToModule( - course = course1, - moduleId = module2.id, - item = lockedAssignment + course = course1, + moduleId = module2.id, + item = lockedAssignment ) // Refresh to get module list update, then assert that module2 is locked @@ -343,138 +556,83 @@ class ModuleInteractionTest : StudentTest() { modulesPage.assertAssignmentLocked(lockedAssignment, course1) } - // Mock a specified number of students and courses, add some assorted assignments, discussions, etc... - // in the form of module items, and navigate to the modules page of the course - private fun getToCourseModules( - studentCount: Int = 1, - courseCount: Int = 1): MockCanvas { - - // Basic info - val data = MockCanvas.init( - studentCount = studentCount, - courseCount = courseCount, - favoriteCourseCount = courseCount) + // Show possible points for assignments in modules if not restricted + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_showPossiblePointsIfNotRestricted() { + val data = getToCourseModules(studentCount = 1, courseCount = 1) + val course = data.courses.values.first() + val module = data.courseModules[course.id]!!.first() - // Add a course tab - val course1 = data.courses.values.first() - val user1 = data.users.values.first() - val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) - data.courseTabs[course1.id]!! += modulesTab + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = false) - // Create a module - val module = data.addModuleToCourse( - course = course1, - moduleName = "Big Module" + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + pointsPossible = 10 ) - // Create a discussion and add it as a module item - topicHeader = data.addDiscussionTopicToCourse( - course = course1, - user = user1, - topicTitle = "Discussion in module", - topicDescription = "In. A. Module." - ) data.addItemToModule( - course = course1, - moduleId = module.id, - item = topicHeader!! + course = course, + moduleId = module.id, + item = assignment, + moduleContentDetails = ModuleContentDetails(pointsPossible = assignment.pointsPossible.toString()) ) - // Create an assignment and add it as a module item - assignment = data.addAssignment( - courseId = course1.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY - ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = assignment!! - ) + modulesPage.refresh() + modulesPage.assertPossiblePointsDisplayed(assignment.pointsPossible.toInt().toString()) + } - // Create a page and add it as a module item - page = data.addPageToCourse( - courseId = course1.id, - pageId = data.newItemId(), - published = true, - title = "Page In Course", - url = URLEncoder.encode("Page In Course", "UTF-8") - ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = page!! - ) + // Hide possible points for assignments in modules if restricted + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.MODULES, TestCategory.INTERACTION, false) + fun testModules_hidePossiblePointsIfRestricted() { + val data = getToCourseModules(studentCount = 1, courseCount = 1) + val course = data.courses.values.first() + val module = data.courseModules[course.id]!!.first() - // Create a file and add it as a module item - val fileContent = "

A Heading

" - fileCheck = WebViewTextCheck(Locator.ID,"heading1","A Heading") + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = true) - val fileId = data.addFileToCourse( - courseId = course1.id, - displayName = fileName, - fileContent = fileContent, - contentType = "text/html" - ) - val rootFolderId = data.courseRootFolders[course1.id]!!.id - val fileFolder = data.folderFiles[rootFolderId]?.find {it.id == fileId} - data.addItemToModule( - course = course1, - moduleId = module.id, - item = fileFolder!! + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + pointsPossible = 10 ) - // Create an external URL and add it as a module item data.addItemToModule( - course = course1, - moduleId = module.id, - item = externalUrl - ) - - // Create a quiz and add it as a module item - quiz = data.addQuizToCourse( - course = course1 + course = course, + moduleId = module.id, + item = assignment, + moduleContentDetails = ModuleContentDetails(pointsPossible = assignment.pointsPossible.toString()) ) - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 1", - questionText = "What is 2 + 5?", - questionType = "multiple_choice_question", - answers = arrayOf( - QuizAnswer(answerText = "7"), - QuizAnswer(answerText = "25"), - QuizAnswer(answerText = "-7") - ) - ) + modulesPage.refresh() + modulesPage.assertPossiblePointsNotDisplayed(assignment.name.orEmpty()) + } - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 2", - questionText = "Pi is greater than the square root of 2", - questionType = "true_false_question" - ) + // Mock a specified number of students and courses, add some assorted assignments, discussions, etc... + // in the form of module items, and navigate to the modules page of the course + private fun getToCourseModules( + studentCount: Int = 1, + courseCount: Int = 1 + ): MockCanvas { - data.addQuestionToQuiz( - course = course1, - quizId = quiz!!.id, - questionName = "Math 3", - questionText = "Write an essay on why math is so awesome", - questionType = "essay_question" + // Basic info + val data = MockCanvas.init( + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = courseCount ) - data.addItemToModule( - course = course1, - moduleId = module.id, - item = quiz!! - ) + // Add a course tab + val course1 = data.courses.values.first() + val modulesTab = Tab(position = 2, label = "Modules", visibility = "public", tabId = Tab.MODULES_ID) + data.courseTabs[course1.id]!! += modulesTab - val ltiTool = data.addLTITool("Google Drive", "http://google.com", course1, 1234L) - data.addItemToModule( + // Create a module + data.addModuleToCourse( course = course1, - moduleId = module.id, - item = ltiTool!! + moduleName = "Big Module" ) // Sign in diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt index bcee97e2af..d79fbf11f1 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NavigationDrawerInteractionTest.kt @@ -37,6 +37,7 @@ import com.instructure.panda_annotations.TestMetaData import com.instructure.student.R import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin +import com.instructure.student.ui.utils.tokenLoginElementary import dagger.hilt.android.testing.HiltAndroidTest import org.hamcrest.CoreMatchers import org.junit.Before @@ -122,28 +123,6 @@ class NavigationDrawerInteractionTest : StudentTest() { loginLandingPage.assertPageObjects() } - /** - * Create two mocked students, sign in the first one, end up on the dashboard page - */ - private fun signInStudent() : MockCanvas { - val data = MockCanvas.init( - studentCount = 2, - courseCount = 1, - favoriteCourseCount = 1 - ) - - student1 = data.students.first() - student2 = data.students.last() - - course = data.courses.values.first() - - val token = data.tokenFor(student1)!! - tokenLogin(data.domain, token, student1) - dashboardPage.waitForRender() - - return data - } - // Should open a dialog and send a question for the selected course // (Checks to see that we can fill out the question and the SEND button exists.) @Test @@ -261,4 +240,62 @@ class NavigationDrawerInteractionTest : StudentTest() { Intents.release() } } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SETTINGS, TestCategory.INTERACTION, false) + fun testMenuItemForDefaultStudent() { + signInStudent() + + leftSideNavigationDrawerPage.assertMenuItems(false) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.SETTINGS, TestCategory.INTERACTION, false) + fun testMenuItemForElementaryStudent() { + signInElementaryStudent() + + leftSideNavigationDrawerPage.assertMenuItems(true) + } + + /** + * Create two mocked students, sign in the first one, end up on the dashboard page + */ + private fun signInStudent(courseCount: Int = 1, studentCount: Int = 2, favoriteCourseCount: Int = 1) : MockCanvas { + val data = MockCanvas.init( + studentCount = studentCount, + courseCount = courseCount, + favoriteCourseCount = favoriteCourseCount + ) + + student1 = data.students.first() + student2 = data.students.last() + + course = data.courses.values.first() + + val token = data.tokenFor(student1)!! + tokenLogin(data.domain, token, student1) + dashboardPage.waitForRender() + + return data + } + + private fun signInElementaryStudent( + courseCount: Int = 1, + pastCourseCount: Int = 0, + favoriteCourseCount: Int = 0, + announcementCount: Int = 0): MockCanvas { + + val data = MockCanvas.init( + studentCount = 1, + courseCount = courseCount, + pastCourseCount = pastCourseCount, + favoriteCourseCount = favoriteCourseCount, + accountNotificationCount = announcementCount) + + val student = data.students[0] + val token = data.tokenFor(student)!! + tokenLoginElementary(data.domain, token, student) + elementaryDashboardPage.waitForRender() + return data + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt new file mode 100644 index 0000000000..3a28429766 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/NotificationInteractionTest.kt @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2019 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.student.ui.interaction + +import com.instructure.canvas.espresso.mockCanvas.* +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.dataseeding.util.ago +import com.instructure.dataseeding.util.days +import com.instructure.dataseeding.util.iso8601 +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test +import java.util.* + +@HiltAndroidTest +class NotificationInteractionTest : StudentTest() { + override fun displaysPageObjects() = Unit // Not used for interaction tests + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testClick_itWorks() { + // Test that push notifications work when you click on them + val data = goToNotifications() + val assignment = data.assignments.values.first() + + notificationPage.assertNotificationDisplayed(assignment.name!!) + notificationPage.clickNotification(assignment.name!!) + + assignmentDetailsPage.assertAssignmentDetails(assignment) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_points() { + val grade = "10.0" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.POINTS, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_percent() { + val grade = "10%" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.PERCENT, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_letter() { + val grade = "A" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.LETTER_GRADE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_gpa() { + val grade = "GPA" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.GPA_SCALE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfNotRestricted_passFail() { + val grade = "complete" + val data = goToNotifications( + restrictQuantitativeData = false, + gradingType = Assignment.GradingType.PASS_FAIL, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeUpdatedIfRestricted_points() { + val grade = "15.0" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.POINTS, + score = 15.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, "C") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_convertGradeIfRestricted_percent() { + val grade = "50%" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.PERCENT, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, "F") + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_letter() { + val grade = "A" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.LETTER_GRADE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_gpa() { + val grade = "GPA" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.GPA_SCALE, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showGradeIfRestricted_passFail() { + val grade = "complete" + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.PASS_FAIL, + score = 10.0, + grade = grade + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertHasGrade(assignment.name!!, grade) + } + + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.NOTIFICATIONS, TestCategory.INTERACTION, false) + fun testNotificationList_showExcused() { + val data = goToNotifications( + restrictQuantitativeData = true, + gradingType = Assignment.GradingType.POINTS, + excused = true + ) + + val assignment = data.assignments.values.first() + + notificationPage.assertExcused(assignment.name!!) + } + + private fun goToNotifications( + numSubmissions: Int = 1, + restrictQuantitativeData: Boolean = false, + gradingType: Assignment.GradingType = Assignment.GradingType.POINTS, + score: Double = -1.0, + grade: String? = null, + excused: Boolean = false + ): MockCanvas { + val data = MockCanvas.init(courseCount = 1, favoriteCourseCount = 1, studentCount = 1, teacherCount = 1) + + val course = data.courses.values.first() + val student = data.students.first() + + val gradingScheme = listOf( + listOf("A", 0.9), + listOf("B", 0.8), + listOf("C", 0.7), + listOf("D", 0.6), + listOf("F", 0.0) + ) + + data.courses[course.id] = course.copy( + settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData), + gradingSchemeRaw = gradingScheme) + + repeat(numSubmissions) { + val assignment = data.addAssignment( + courseId = course.id, + submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY, + gradingType = Assignment.gradingTypeToAPIString(gradingType).orEmpty(), + pointsPossible = 20 + ) + + val submission = data.addSubmissionForAssignment( + assignmentId = assignment.id, + userId = student.id, + type = Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, + body = "Some words + ${UUID.randomUUID()}" + ) + + data.addSubmissionStreamItem( + user = student, + course = course, + assignment = assignment, + submission = submission, + submittedAt = 1.days.ago.iso8601, + type = "submission", + score = score, + grade = grade, + excused = excused + ) + } + + val token = data.tokenFor(student)!! + tokenLogin(data.domain, token, student) + + dashboardPage.waitForRender() + dashboardPage.clickNotificationsTab() + + return data + } +} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt index df94833479..3d3bffea00 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PickerSubmissionUploadInteractionTest.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.net.Uri import androidx.test.espresso.intent.Intents import androidx.test.espresso.intent.matcher.IntentMatchers +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import com.instructure.canvas.espresso.Stub import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment @@ -52,6 +53,9 @@ class PickerSubmissionUploadInteractionTest : StudentTest() { // Read this at set-up, because it may become nulled out soon thereafter activity = activityRule.activity + //Clear file upload cache dir. + File(getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + // Copy our sample file from the assets area to the external cache dir copyAssetFileToExternalCache(activity, mockedFileName) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt deleted file mode 100644 index 3a68401947..0000000000 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/PushNotificationInteractionTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (C) 2019 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.student.ui.interaction - -import com.instructure.canvas.espresso.mockCanvas.* -import com.instructure.canvasapi2.models.Assignment -import com.instructure.dataseeding.util.ago -import com.instructure.dataseeding.util.days -import com.instructure.dataseeding.util.iso8601 -import com.instructure.panda_annotations.FeatureCategory -import com.instructure.panda_annotations.Priority -import com.instructure.panda_annotations.TestCategory -import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.utils.StudentTest -import com.instructure.student.ui.utils.tokenLogin -import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Test -import java.util.* - -@HiltAndroidTest -class PushNotificationInteractionTest : StudentTest() { - override fun displaysPageObjects() = Unit // Not used for interaction tests - - @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.NONE, TestCategory.INTERACTION, false) - fun testClick_itWorks() { - // Test that push notifications work when you click on them - val data = goToNotifications() - val assignment = data.assignments.values.first() - - notificationPage.assertNotificationDisplayed(assignment.name!!) - notificationPage.clickNotification(assignment.name!!) - - assignmentDetailsPage.assertAssignmentDetails(assignment) - } - - private fun goToNotifications(numSubmissions: Int = 1) : MockCanvas { - val data = MockCanvas.init(courseCount = 1, favoriteCourseCount = 1, studentCount = 1, teacherCount = 1) - - val course = data.courses.values.first() - val student = data.students.first() - - repeat(numSubmissions) { - val assignment = data.addAssignment( - courseId = course.id, - submissionType = Assignment.SubmissionType.ONLINE_TEXT_ENTRY - ) - - val submission = data.addSubmissionForAssignment( - assignmentId = assignment.id, - userId = student.id, - type = Assignment.SubmissionType.ONLINE_TEXT_ENTRY.apiString, - body = "Some words + ${UUID.randomUUID()}" - ) - - val streamItem = data.addSubmissionStreamItem( - user = student, - course = course, - assignment = assignment, - submission = submission, - submittedAt = 1.days.ago.iso8601, - type = "submission" - ) - } - - val token = data.tokenFor(student)!! - tokenLogin(data.domain, token, student) - - dashboardPage.waitForRender() - dashboardPage.clickNotificationsTab() - - return data - } - -} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt new file mode 100644 index 0000000000..d35583483c --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/QuizListInteractionTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.student.ui.interaction + +import com.instructure.canvas.espresso.mockCanvas.MockCanvas +import com.instructure.canvas.espresso.mockCanvas.addQuizToCourse +import com.instructure.canvas.espresso.mockCanvas.init +import com.instructure.canvasapi2.models.CourseSettings +import com.instructure.canvasapi2.models.Quiz +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Test + +@HiltAndroidTest +class QuizListInteractionTest : StudentTest() { + + override fun displaysPageObjects() = Unit + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysNoQuizzesView() { + getToQuizListPage(0) + quizListPage.assertNoQuizDisplayed() + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuiz() { + val quiz = getToQuizListPage(1)[0] + quizListPage.assertQuizDisplayed(quiz) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizzes() { + val quizzes = getToQuizListPage(5) + quizListPage.assertQuizItemCount(quizzes.size) + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizWithPointsIfNotRestrictQuantitativeData() { + val quiz = getToQuizListPage(1)[0] + quizListPage.assertPointsDisplayed("${quiz.pointsPossible} points") + } + + @Test + @TestMetaData(Priority.IMPORTANT, FeatureCategory.QUIZZES, TestCategory.INTERACTION) + fun displaysQuizWithoutPointsIfRestrictQuantitativeData() { + getToQuizListPage(1, true) + quizListPage.assertPointsNotDisplayed() + } + + private fun getToQuizListPage(itemCount: Int = 1, restrictQuantitativeData: Boolean = false): List { + val data = MockCanvas.init( + courseCount = 1, + favoriteCourseCount = 1, + studentCount = 1, + teacherCount = 1 + ) + + val course = data.courses.values.first() + data.courseSettings[course.id] = CourseSettings(restrictQuantitativeData = restrictQuantitativeData) + val student = data.students.first() + val quizList = mutableListOf() + data.courseQuizzes[course.id] = mutableListOf() + repeat(itemCount) { + val quiz = data.addQuizToCourse(course, pointsPossible = 10) + quizList.add(quiz) + } + val token = data.tokenFor(student)!! + + tokenLogin(data.domain, token, student) + dashboardPage.waitForRender() + dashboardPage.selectCourse(course) + courseBrowserPage.selectQuizzes() + + return quizList + } +} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt index 91e77751f1..f19c657307 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/ShareExtensionInteractionTest.kt @@ -16,28 +16,23 @@ */ package com.instructure.student.ui.interaction -import android.app.Activity -import android.app.Instrumentation import android.content.Intent import android.net.Uri -import androidx.core.content.FileProvider import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiSelector import com.instructure.canvas.espresso.Stub +import com.instructure.canvas.espresso.StubTablet import com.instructure.canvas.espresso.mockCanvas.MockCanvas import com.instructure.canvas.espresso.mockCanvas.addAssignment import com.instructure.canvas.espresso.mockCanvas.init import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.User -import com.instructure.pandautils.utils.Const import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest -import org.hamcrest.core.AllOf import org.junit.Test import java.io.File @@ -91,6 +86,8 @@ class ShareExtensionInteractionTest : StudentTest() { } @Test + @StubTablet("Stubbed in Tablet because on Firebase it's breaking the workflow while actually no tests cases has failed." + + "Once the reason will be figured out, we will put back this test to tablet as well.") fun addAndRemoveFileFromFileUploadDialog() { val data = createMockData() val student = data.students[0] @@ -277,20 +274,6 @@ class ShareExtensionInteractionTest : StudentTest() { tokenLogin(MockCanvas.data.domain, token!!, student) } - private fun setupFileOnDevice(fileName: String): Uri { - copyAssetFileToExternalCache(activityRule.activity, fileName) - - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - - val instrumentationContext = InstrumentationRegistry.getInstrumentation().context - return FileProvider.getUriForFile( - instrumentationContext, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - } - private fun shareExternalFile(uri: Uri) { val intent = Intent().apply { action = Intent.ACTION_SEND @@ -316,23 +299,4 @@ class ShareExtensionInteractionTest : StudentTest() { InstrumentationRegistry.getInstrumentation().context.startActivity(chooser) } - - private fun stubFilePickerIntent(fileName: String) { - val resultData = Intent() - val dir = activityRule.activity.externalCacheDir - val file = File(dir?.path, fileName) - val newFileUri = FileProvider.getUriForFile( - activityRule.activity, - "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, - file - ) - resultData.data = newFileUri - - Intents.intending( - AllOf.allOf( - IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT), - IntentMatchers.hasType("*/*"), - ) - ).respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData)) - } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt index 831b2f9fca..26c6b09593 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AnnouncementListPage.kt @@ -16,14 +16,17 @@ */ package com.instructure.student.ui.pages -import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.matchers.WaitForViewMatcher -import com.instructure.espresso.page.* +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withParent +import com.instructure.espresso.page.withText import com.instructure.student.R -class AnnouncementListPage : BasePage(R.id.discussionListPage) { +class AnnouncementListPage(searchable: Searchable) : DiscussionListPage(searchable) { fun assertToolbarTitle() { WaitForViewMatcher.waitForView(withParent(R.id.discussionListToolbar) + withText(R.string.announcements)).assertDisplayed() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt index 03620abba6..5164267d7f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentDetailsPage.kt @@ -36,11 +36,13 @@ import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertContainsText import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithText import com.instructure.espresso.page.withAncestor @@ -49,6 +51,7 @@ import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown +import com.instructure.espresso.swipeUp import com.instructure.espresso.typeText import com.instructure.espresso.waitForCheck import com.instructure.student.R @@ -94,9 +97,35 @@ open class AssignmentDetailsPage : BasePage(R.id.assignmentDetailsPage) { onView(allOf(withId(R.id.submissionStatus), withText(R.string.gradedSubmissionLabel))).scrollTo().assertDisplayed() } + fun assertGradeDisplayed(grade: String) { + onView(withId(R.id.gradeCell)).scrollTo().assertDisplayed() + onView(withId(R.id.grade)).scrollTo().assertContainsText(grade) + } + + fun assertGradeNotDisplayed() { + onView(withId(R.id.grade)).assertNotDisplayed() + } + + fun assertOutOfTextDisplayed(outOfText: String) { + onView(withId(R.id.outOf)).scrollTo().assertContainsText(outOfText) + } + + fun assertOutOfTextNotDisplayed() { + onView(withId(R.id.outOf)).assertNotDisplayed() + } + + fun assertScoreDisplayed(score: String) { + onView(withId(R.id.score)).scrollTo().assertContainsText(score) + } + + fun assertScoreNotDisplayed() { + onView(withId(R.id.score)).assertNotDisplayed() + } + fun assertAssignmentLocked() { + if(CanvasTest.isLandscapeDevice()) onView(withId(R.id.swipeRefreshLayout) + withAncestor(R.id.assignmentDetailsPage)).swipeUp() onView(withId(R.id.lockedMessageTextView)).assertDisplayed() - onView(withId(R.id.lockedMessageTextView)).check(matches(containsTextCaseInsensitive("this assignment is locked"))) + onView(withId(R.id.lockedMessageTextView)).check(matches(containsTextCaseInsensitive("this assignment is locked by the module"))) } fun refresh() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt index 0db8c30d0d..089ac62d90 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AssignmentListPage.kt @@ -27,14 +27,34 @@ import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvasapi2.models.Assignment import com.instructure.dataseeding.model.AssignmentApiModel import com.instructure.dataseeding.model.QuizApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.WaitForViewWithText +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasText +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.assertVisible +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithText +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString -class AssignmentListPage : BasePage(pageResId = R.id.assignmentListPage) { +class AssignmentListPage(val searchable: Searchable) : BasePage(pageResId = R.id.assignmentListPage) { private val assignmentListToolbar by OnViewWithId(R.id.toolbar) private val gradingPeriodHeader by WaitForViewWithId(R.id.termSpinnerLayout) @@ -72,12 +92,12 @@ class AssignmentListPage : BasePage(pageResId = R.id.assignmentListPage) { assertHasAssignmentCommon(assignment.name!!, assignment.dueAt, expectedGrade) } - fun clickOnSearchButton() { - onView(withId(R.id.search)).click() - } - - fun typeToSearchBar(textToType: String) { - waitForViewWithId(R.id.search_src_text).replaceText(textToType) + fun assertAssignmentDisplayedWithGrade(assignmentName: String, gradeString: String) { + val matcher = withId(R.id.title) + withParent(R.id.textContainer) + withText(assignmentName) + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertDisplayed() + val pointsMatcher = withId(R.id.title) + withText(assignmentName) + onView(withId(R.id.points) + withParent(hasSibling(pointsMatcher))).assertHasText(gradeString) } fun assertAssignmentNotDisplayed(assignmentName: String) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/BookmarkPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/BookmarkPage.kt index 54c6b73b47..6d089573a3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/BookmarkPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/BookmarkPage.kt @@ -17,12 +17,10 @@ package com.instructure.student.ui.pages import androidx.appcompat.widget.AppCompatButton +import androidx.test.espresso.Espresso import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.* +import com.instructure.canvas.espresso.CanvasTest import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.espresso.assertDisplayed @@ -65,6 +63,7 @@ class BookmarkPage : BasePage() { onView(withId(R.id.bookmarkEditText)).typeText(newName) // Save + if(CanvasTest.isLandscapeDevice()) Espresso.pressBack() //need to remove soft-keyboard on landscape devices onView(allOf(isAssignableFrom(AppCompatButton::class.java), containsTextCaseInsensitive("DONE"))).click() } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt index ed7787bf7b..cc2a97e5eb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CanvasWebViewPage.kt @@ -17,6 +17,8 @@ package com.instructure.student.ui.pages import androidx.annotation.StringRes +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches import androidx.test.espresso.web.model.Atoms.getCurrentUrl @@ -28,8 +30,17 @@ import androidx.test.espresso.web.webdriver.DriverAtoms.webScrollIntoView import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.withElementRepeat import com.instructure.espresso.assertVisible -import com.instructure.espresso.page.* +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.student.R +import com.instructure.student.ui.utils.TypeInRCETextEditor import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.containsString @@ -93,6 +104,23 @@ open class CanvasWebViewPage : BasePage(R.id.contentWebView) { fun waitForWebView() { waitForView(allOf(withId(R.id.contentWebView), isDisplayed())) } + + fun clickEditPencilIcon() { + onView(withId(R.id.menu_edit)).click() + } + + fun assertDoesNotEditable() { + onView(withId(R.id.menu_edit)).check(doesNotExist()) + } + + fun typeInRCEEditor(textToType: String) { + waitForView(ViewMatchers.withId(R.id.rce_webView)).perform(TypeInRCETextEditor(textToType)) + } + + fun clickOnSave() { + onViewWithId(R.id.menuSavePage).click() + } + } /** data class that encapsulates info for a webview text check */ diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt index 723ce6ccce..d3a9a95924 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt @@ -27,17 +27,20 @@ import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.Course -import com.instructure.canvasapi2.models.Group import com.instructure.canvasapi2.models.Tab import com.instructure.dataseeding.model.CourseApiModel -import com.instructure.espresso.* +import com.instructure.espresso.TextViewColorAssertion +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.assertHasText +import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.swipeUp import com.instructure.pandautils.views.SwipeRefreshLayoutAppBar import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { +open class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { private val initialBrowserTitle by WaitForViewWithId(R.id.courseBrowserTitle) @@ -117,10 +120,6 @@ class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { onView(allOf(withId(R.id.courseBrowserTitle), isDisplayed())).assertHasText(course.originalName!!) } - fun assertTitleCorrect(group: Group) { - onView(allOf(withId(R.id.courseBrowserTitle), isDisplayed())).assertHasText(group.name!!) - } - fun assertTitleCorrect(course: CourseApiModel) { initialBrowserTitle.assertHasText(course.name) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt index 521c4e9162..3261c98c36 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt @@ -33,6 +33,7 @@ import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasText import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView @@ -41,13 +42,14 @@ import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -import java.util.concurrent.* +import java.util.concurrent.TimeUnit class CourseGradesPage : BasePage(R.id.courseGradesPage) { private val gradeLabel by WaitForViewWithId(R.id.txtOverallGradeLabel) @@ -79,6 +81,12 @@ class CourseGradesPage : BasePage(R.id.courseGradesPage) { gradeValue.check(matches(matcher)) } + fun assertAssignmentDisplayed(name: String, gradeString: String) { + onView(withId(R.id.title) + withParent(R.id.textContainer)).assertHasText(name) + val siblingMatcher = withId(R.id.title) + withText(name) + onView(withId(R.id.points) + hasSibling(siblingMatcher)).assertHasText(gradeString) + } + // Hopefully this will be sufficient. We may need to add some logic to scroll // to the top of the list first. We have to use the custom constraints because the // swipeRefreshLayout may extend below the screen, and therefore may not be 90% visible. diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 05953754ff..caa43663b0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -166,6 +166,10 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(withId(R.id.gradeTextView)).assertDisplayed() } + fun assertGradeText(gradeText: String) { + onViewWithId(R.id.gradeTextView).assertHasText(gradeText) + } + // Assumes one course, which is favorited fun assertHidesGrades() { onView(withId(R.id.gradeTextView)).assertNotDisplayed() @@ -186,6 +190,11 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(withText(course.name) + withId(R.id.titleTextView)).click() } + fun selectGroup(group: GroupApiModel) { + val groupNameMatcher = allOf(withText(group.name), withId(R.id.groupNameView)) + onView(groupNameMatcher).scrollTo().click() + } + fun assertAnnouncementShowing(announcement: AccountNotification) { onView(withId(R.id.announcementIcon)).assertDisplayed() onView(withId(R.id.announcementTitle) + withText(announcement.subject)).assertDisplayed() @@ -270,9 +279,18 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(matcher).check(doesNotExist()) } + fun assertGroupNotDisplayed(group: Group) { + val matcher = allOf( + withText(group.name), + withId(R.id.titleTextView), + withAncestor(R.id.swipeRefreshLayout) + ) + onView(matcher).check(doesNotExist()) + } + fun changeCourseNickname(changeTo: String) { onView(withId(R.id.newCourseNickname)).replaceText(changeTo) - onView(withText(R.string.ok) + withAncestor(R.id.buttonPanel)).click() + onView(withText(android.R.string.ok) + withAncestor(R.id.buttonPanel)).click() } fun clickCourseOverflowMenu(courseTitle: String, menuTitle: String) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt index a27751758d..c69c5c1484 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionDetailsPage.kt @@ -26,9 +26,7 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.web.assertion.WebViewAssertions.webMatches import androidx.test.espresso.web.sugar.Web.onWebView -import androidx.test.espresso.web.webdriver.DriverAtoms.findElement -import androidx.test.espresso.web.webdriver.DriverAtoms.getText -import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.DriverAtoms.* import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.* import com.instructure.canvasapi2.models.DiscussionEntry @@ -55,8 +53,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertDescriptionText(descriptionText: String) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) - .withElement(findElement(Locator.ID,"content")) - .check(webMatches(getText(), containsString(descriptionText))) + .withElement(findElement(Locator.ID, "content")) + .check(webMatches(getText(), containsString(descriptionText))) } fun assertTopicInfoShowing(topicHeader: DiscussionTopicHeader) { @@ -64,16 +62,16 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { assertDescriptionText(topicHeader.message!!) } - fun clickLinkInDescription(linkElementId : String) { - onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) - .withElement(findElement(Locator.ID,linkElementId)) - .perform(webClick()) + fun clickLinkInDescription(linkElementId: String) { + onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionTopicHeaderWebViewWrapper)) + .withElement(findElement(Locator.ID, linkElementId)) + .perform(webClick()) } fun refresh() { scrollToTop() onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayingAtLeast(10))) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) } fun scrollToRepliesWebview() { @@ -116,14 +114,13 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertReplyDisplayed(reply: DiscussionEntry, refreshesAllowed: Int = 0) { // Allow up to refreshesAllowed attempt/refresh cycles - for(i in 0..refreshesAllowed-1) { + for (i in 0 until refreshesAllowed) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.ID, "message_content_${reply.id}")) - .check(webMatches(getText(),containsString(reply.message))) + .withElement(findElement(Locator.ID, "message_content_${reply.id}")) + .check(webMatches(getText(), containsString(reply.message))) return - } - catch(t: Throwable) { + } catch (t: Throwable) { refresh() } } @@ -134,8 +131,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // (It can take a *long* time for the reply to get rendered to the webview on // tablets (in FTL, anyway).) onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.ID, "message_content_${reply.id}"), 3) - .check(webMatches(getText(),containsString(reply.message))) + .withElementRepeat(findElement(Locator.ID, "message_content_${reply.id}"), 3) + .check(webMatches(getText(), containsString(reply.message))) } fun assertReplyDisplayed(reply: DiscussionEntry) { @@ -152,9 +149,8 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertFavoritingEnabled(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) - } - catch(t: Throwable) { + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + } catch (t: Throwable) { assertTrue("Favoriting icon is disabled", false) } } @@ -162,31 +158,28 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { fun assertFavoritingDisabled(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) // We shouldn't reach this point if the favoriting icon is disabled -- we should throw assertTrue("Favoriting icon is enabled", false) - } - catch(t: Throwable) { - } + } catch (_: Throwable) {} } fun clickLikeOnEntry(reply: DiscussionEntry) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) - .perform(webClick()) + .withElement(findElement(Locator.CLASS_NAME, "likes_icon_wrapper_${reply.id}")) + .perform(webClick()) } fun assertLikeCount(reply: DiscussionEntry, count: Int, refreshesAllowed: Int = 0) { - if(count > 0) { + if (count > 0) { - for(i in 0..refreshesAllowed-1) { + for (i in 0 until refreshesAllowed) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) - .check(webMatches(getText(), containsString(count.toString()))) + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .check(webMatches(getText(), containsString(count.toString()))) return - } - catch(t: Throwable) { + } catch (t: Throwable) { refresh() } } @@ -194,52 +187,48 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // If we haven't verified our info by now, let's make one last call to either // (1) succeed or (2) throw a sensible error. onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) - .check(webMatches(getText(), containsString(count.toString()))) - } - else { + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .check(webMatches(getText(), containsString(count.toString()))) + } else { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "likes_count_${reply.id}")) assertTrue("Didn't expect to see like count with 0 count", false) - } - catch(t: Throwable) { } - + } catch (_: Throwable) {} } } fun assertReplyAttachment(reply: DiscussionEntry) { try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.CLASS_NAME, "attachments_${reply.id}")) - } - catch(t: Throwable) { + .withElement(findElement(Locator.CLASS_NAME, "attachments_${reply.id}")) + } catch (t: Throwable) { assertTrue("Discussion entry did not have an attachment", false) } } - fun previewAndCheckReplyAttachment(reply: DiscussionEntry, vararg checks : WebViewTextCheck) { + fun previewAndCheckReplyAttachment(reply: DiscussionEntry, vararg checks: WebViewTextCheck) { // Sometimes clicking the attachment logo fails to do anything. // We'll give it 3 chances. var triesRemaining = 3; - while(!isElementDisplayed(R.id.canvasWebViewWrapper) && triesRemaining > 0) { - if(triesRemaining < 3) { + while (!isElementDisplayed(R.id.canvasWebViewWrapper) && triesRemaining > 0) { + if (triesRemaining < 3) { refresh() // Maybe web content was incorrectly rendered? Try again sleep(1500) // Allow webview some time to render } onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.CLASS_NAME, "attachments_${reply.id}"), 20) - .perform(webClick()) + .withElementRepeat(findElement(Locator.CLASS_NAME, "attachments_${reply.id}"), 20) + .perform(webClick()) triesRemaining -= 1 } assertTrue("FAILED to bring up reply attachment", isElementDisplayed(R.id.canvasWebViewWrapper)); - for(check in checks) { + for (check in checks) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.canvasWebViewWrapper)) - .withElement(findElement(check.locatorType, check.locatorValue)) - .check(webMatches(getText(), containsString(check.textValue))) + .withElement(findElement(check.locatorType, check.locatorValue)) + .check(webMatches(getText(), containsString(check.textValue))) } Espresso.pressBack() @@ -250,14 +239,14 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { // It appears that sometimes the click to reply doesn't work. // Let's give it 3 chances. var triesRemaining = 3 - while(!isElementDisplayed(R.id.rce_webView) && triesRemaining > 0) { - if(triesRemaining < 3) { + while (!isElementDisplayed(R.id.rce_webView) && triesRemaining > 0) { + if (triesRemaining < 3) { refresh() // maybe the html was rendered badly and needs refreshing? sleep(2000) // A little time for the webview to render } onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElementRepeat(findElement(Locator.ID, "reply_${reply.id}"), 20) - .perform(webClick()) + .withElementRepeat(findElement(Locator.ID, "reply_${reply.id}"), 20) + .perform(webClick()) triesRemaining -= 1 } @@ -278,10 +267,10 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { */ fun previewAndCheckMainAttachment(vararg checks: WebViewTextCheck) { onView(withId(R.id.attachmentIcon)).click() - for(check in checks) { + for (check in checks) { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.canvasWebViewWrapper)) - .withElement(findElement(check.locatorType, check.locatorValue)) - .check(webMatches(getText(), containsString(check.textValue))) + .withElement(findElement(check.locatorType, check.locatorValue)) + .check(webMatches(getText(), containsString(check.textValue))) } Espresso.pressBack() } @@ -298,7 +287,7 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { */ fun waitForUnreadIndicatorToDisappear(reply: DiscussionEntry) { repeat(10) { - if(!isUnreadIndicatorVisible(reply)) return + if (!isUnreadIndicatorVisible(reply)) return sleep(1000) } @@ -309,24 +298,23 @@ class DiscussionDetailsPage : BasePage(R.id.discussionDetailsPage) { onView(withId(R.id.pointsTextView)).check(matches(containsTextCaseInsensitive(points))) } - private fun isUnreadIndicatorVisible(reply: DiscussionEntry) : Boolean { - try { + fun assertPointsPossibleNotDisplayed() { + onView(withId(R.id.pointsTextView)).assertNotDisplayed() + } + + private fun isUnreadIndicatorVisible(reply: DiscussionEntry): Boolean { + return try { onWebView(withId(R.id.contentWebView) + withAncestor(R.id.discussionRepliesWebViewWrapper)) - .withElement(findElement(Locator.ID, "unread_indicator_${reply.id}")) - .withElement(findElement(Locator.CLASS_NAME, "unread")) - return true - } - catch(t: Throwable) { - return false + .withElement(findElement(Locator.ID, "unread_indicator_${reply.id}")) + .withElement(findElement(Locator.CLASS_NAME, "unread")) + true + } catch (t: Throwable) { + false } } - fun scrollToTop() { + private fun scrollToTop() { onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayingAtLeast(10))) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) } } - - - - diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt index 86e3054fb3..28f125e1a9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DiscussionListPage.kt @@ -25,14 +25,31 @@ import com.instructure.canvas.espresso.explicitClick import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.waitForMatcherWithRefreshes import com.instructure.canvasapi2.models.DiscussionTopicHeader -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withDescendant +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.waitForCheck import com.instructure.student.R import com.instructure.student.ui.utils.TypeInRCETextEditor import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.containsString -class DiscussionListPage : BasePage(R.id.discussionListPage) { +open class DiscussionListPage(val searchable: Searchable) : BasePage(R.id.discussionListPage) { private val createNewDiscussion by OnViewWithId(R.id.createNewDiscussion) private val announcementsRecyclerView by OnViewWithId(R.id.discussionRecyclerView) @@ -71,14 +88,20 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { fun assertReplyCount(topicTitle: String, count: Int) { val matcher = allOf( withId(R.id.readUnreadCounts), - withText(containsString("$count Repl")), // Could be "Reply" or "Replies" + withText(anyOf(containsString("$count Reply"), containsString("$count Replies"))), // Could be "Reply" or "Replies" hasSibling(allOf( withId(R.id.discussionTitle), withText(topicTitle) ))) - scrollRecyclerView(R.id.discussionRecyclerView, matcher) - onView(matcher).assertDisplayed() // probably redundant + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertUnreadReplyCount(topicTitle: String, count: Int) { + val matcher = allOf(withId(R.id.readUnreadCounts), withText(containsString("$count Unread")), + hasSibling(allOf(withId(R.id.discussionTitle), withText(topicTitle)))) + + onView(matcher).scrollTo().assertDisplayed() } fun assertUnreadCount(topicTitle: String, count: Int) { @@ -126,18 +149,6 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { onView(withContentDescription("Close")).click() } - fun clickOnSearchButton() { - onView(withId(R.id.search)).click() - } - - fun typeToSearchBar(textToType: String) { - waitForViewWithId(R.id.search_src_text).replaceText(textToType) - } - - fun clickOnClearSearchButton() { - onView(withId(R.id.search_close_btn)).click() - } - fun verifyExitWithoutSavingDialog() { onView(withText(R.string.exitWithoutSavingMessage)).check(matches(isDisplayed())) } @@ -176,4 +187,9 @@ class DiscussionListPage : BasePage(R.id.discussionListPage) { val ancestorMatcher = allOf(withId(R.id.discussionLayout), withDescendant(withId(R.id.discussionTitle) + withText(announcementName))) onView(allOf(withId(R.id.nestedIcon), withContentDescription(R.string.locked), withAncestor(ancestorMatcher))).assertDisplayed() } + + fun assertDueDate(topicTitle: String, expectedDateString: String) { + val matcher = allOf(withId(R.id.dueDate), withText(containsString(expectedDateString)), hasSibling(allOf(withId(R.id.discussionTitle), withText(topicTitle)))) + onView(matcher).scrollTo().assertDisplayed() + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt index f625de7dd1..7ebf099da4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/FileListPage.kt @@ -17,32 +17,37 @@ package com.instructure.student.ui.pages import androidx.appcompat.widget.AppCompatButton -import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.swipeDown -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast -import androidx.test.espresso.matcher.ViewMatchers.withChild -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.clearText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage -import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.scrollTo import com.instructure.espresso.typeText import com.instructure.student.R import org.hamcrest.Matchers.allOf // Tests that files submitted for submissions, submission comments and discussions are // properly displayed. -class FileListPage : BasePage(R.id.fileListPage) { +class FileListPage(val searchable: Searchable) : BasePage(R.id.fileListPage) { private val addButton by OnViewWithId(R.id.addFab) private val uploadFileButton by OnViewWithId(R.id.addFileFab, autoAssert = false) @@ -50,8 +55,12 @@ class FileListPage : BasePage(R.id.fileListPage) { fun assertItemDisplayed(itemName: String) { val matcher = allOf(withId(R.id.fileName), withText(itemName)) - scrollRecyclerView(R.id.listView, matcher) - onView(matcher).assertDisplayed() + waitForView(matcher).scrollTo().assertDisplayed() + } + + fun assertItemNotDisplayed(itemName: String) { + val matcher = allOf(withId(R.id.fileName), withText(itemName)) + onView(matcher).assertNotDisplayed() } fun selectItem(itemName: String) { @@ -61,11 +70,20 @@ class FileListPage : BasePage(R.id.fileListPage) { } fun clickAddButton() { - addButton.click() + onView(allOf(withId(R.id.addFab), isDisplayed())).perform(click()) } fun clickUploadFileButton() { - uploadFileButton.click() + onView(allOf(withId(R.id.addFileFab), isDisplayed())).perform(click()) + } + + fun clickCreateNewFolderButton() { + newFolderButton.click() + } + + fun createNewFolder(folderName: String) { + waitForViewWithId(R.id.textInput).typeText(folderName) + onView(withText(R.string.ok)).click() } fun assertPdfPreviewDisplayed() { @@ -90,6 +108,7 @@ class FileListPage : BasePage(R.id.fileListPage) { onView(withId(R.id.textInput)).clearText() onView(withId(R.id.textInput)).typeText(newName) onView(containsTextCaseInsensitive("OK")).click() + Espresso.pressBack() //Close soft keyboard refresh() } @@ -104,4 +123,22 @@ class FileListPage : BasePage(R.id.fileListPage) { // to distinguish from other emptyViews in the stack. onView(allOf(withId(R.id.emptyView), isDisplayed())).assertDisplayed() } + + fun assertSearchResultCount(expectedCount: Int) { + Thread.sleep(2000) + onView(withId(R.id.fileSearchRecyclerView) + withAncestor(R.id.container)).check( + ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + ) + } + + fun assertFileListCount(expectedCount: Int) { + Thread.sleep(2000) + onView(withId(R.id.listView) + withAncestor(R.id.container)).check( + ViewAssertions.matches(ViewMatchers.hasChildCount(expectedCount)) + ) + } + + fun assertFolderSize(folderName: String, expectedSize: Int) { + waitForView(allOf(withId(R.id.fileSize), hasSibling(withId(R.id.fileName) + withText(folderName)))).check(matches(containsTextCaseInsensitive("$expectedSize ${if (expectedSize == 1) "item" else "items"}"))) + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt index 5b8ac2aa3f..1bb0b641b4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GradesPage.kt @@ -16,10 +16,9 @@ */ package com.instructure.student.ui.pages -import android.view.View import androidx.test.espresso.NoMatchingViewException -import androidx.test.espresso.PerformException import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.assertNotDisplayed @@ -30,14 +29,12 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.scrollTo import com.instructure.espresso.page.withDescendant import com.instructure.espresso.page.withId -import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeDown import com.instructure.espresso.swipeUp import com.instructure.pandautils.binding.BindableViewHolder import com.instructure.student.R -import org.hamcrest.Matcher class GradesPage : BasePage(R.id.gradesPage) { @@ -81,6 +78,11 @@ class GradesPage : BasePage(R.id.gradesPage) { gradesRecyclerView.assertNotDisplayed() } + fun assertProgressNotDisplayed(courseName: String) { + val courseNameMatcher = withId(R.id.gradesCourseNameText) + withText(courseName) + onView(withId(R.id.progressLayout) + hasSibling(courseNameMatcher)).assertNotDisplayed() + } + fun clickGradeRow(courseName: String) { onView(withId(R.id.gradesCourseNameText) + withText(courseName)) .scrollTo() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt new file mode 100644 index 0000000000..3ea8d62fc5 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/GroupBrowserPage.kt @@ -0,0 +1,30 @@ +package com.instructure.student.ui.pages + +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import com.instructure.canvasapi2.models.Group +import com.instructure.dataseeding.model.GroupApiModel +import com.instructure.espresso.assertHasText +import com.instructure.student.R +import org.hamcrest.Matchers + +class GroupBrowserPage : CourseBrowserPage() { + + fun assertTitleCorrect(group: Group) { + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.courseBrowserTitle), + ViewMatchers.isDisplayed() + ) + ).assertHasText(group.name!!) + } + + fun assertTitleCorrect(group: GroupApiModel) { + Espresso.onView( + Matchers.allOf( + ViewMatchers.withId(R.id.courseBrowserTitle), + ViewMatchers.isDisplayed() + ) + ).assertHasText(group.name!!) + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt index a2c47fd12b..8958de26f9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ImportantDatesPage.kt @@ -21,8 +21,21 @@ import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.canvas.espresso.countConstraintLayoutsInRecyclerView +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertHasChild +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.swipeUp import com.instructure.pandautils.binding.BindableViewHolder import com.instructure.student.R import org.hamcrest.Matcher @@ -56,7 +69,9 @@ class ImportantDatesPage : BasePage(R.id.importantDatesPage) { } fun assertRecyclerViewItemCount(expectedCount: Int) { - importantDatesRecyclerView.check(RecyclerViewItemCountAssertion(expectedCount)) + val importantDatesCount = + countConstraintLayoutsInRecyclerView(importantDatesRecyclerView) + assert(importantDatesCount == expectedCount) } fun assertDayTextIsDisplayed(dayText: String) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt index 223ceaa4de..29dd3fc338 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt @@ -141,12 +141,11 @@ class InboxPage : BasePage(R.id.inboxPage) { fun assertConversationNotStarred(subject: String) { val matcher = allOf( withId(R.id.star), - withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), hasSibling(withId(R.id.userName)), hasSibling(withId(R.id.date)), hasSibling(allOf(withId(R.id.subjectView), withText(subject)))) waitForMatcherWithRefreshes(matcher) // May need to refresh before the star shows up - onView(matcher).check(doesNotExist()) + onView(matcher).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt index bcd295b004..9842cbf0bc 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt @@ -8,32 +8,50 @@ import androidx.test.espresso.ViewAction import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withText +import com.instructure.canvas.espresso.CanvasTest import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.OnViewWithContentDescription import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.onViewWithId import com.instructure.espresso.page.onViewWithText import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.withId import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.swipeUp import com.instructure.student.R import org.hamcrest.CoreMatchers import org.hamcrest.Matcher class LeftSideNavigationDrawerPage: BasePage() { - private val settings by OnViewWithId(R.id.navigationDrawerSettings) + private val hamburgerButton by OnViewWithContentDescription(R.string.navigation_drawer_open) + + // User data + private val profileImage by OnViewWithId(R.id.navigationDrawerProfileImage) private val userName by OnViewWithId(R.id.navigationDrawerUserName) private val userEmail by OnViewWithId(R.id.navigationDrawerUserEmail) + + // Navigation items + private val files by OnViewWithId(R.id.navigationDrawerItem_files) + private val bookmarks by OnViewWithId(R.id.navigationDrawerItem_bookmarks) + private val settings by OnViewWithId(R.id.navigationDrawerSettings) + + //Option items + private val showGrades by OnViewWithId(R.id.navigationDrawerItem_showGrades) + private val colorOverlay by OnViewWithId(R.id.navigationDrawerItem_colorOverlay) + + // Account items + private val help by OnViewWithId(R.id.navigationDrawerItem_help) private val changeUser by OnViewWithId(R.id.navigationDrawerItem_changeUser) private val logoutButton by OnViewWithId(R.id.navigationDrawerItem_logout) - private val version by OnViewWithId(R.id.navigationDrawerVersion) - private val hamburgerButton by OnViewWithContentDescription(R.string.navigation_drawer_open) // Sometimes when we navigate back to the dashboard page, there can be several hamburger buttons // in the UI stack. We want to choose the one that is displayed. @@ -112,6 +130,52 @@ class LeftSideNavigationDrawerPage: BasePage() { Espresso.pressBack() } + fun assertMenuItems(isElementaryStudent: Boolean) { + hamburgerButton.click() + userName.assertDisplayed() + userEmail.assertDisplayed() + + settings.assertDisplayed() + + if(CanvasTest.isLandscapeDevice() || CanvasTest.isLowResDevice()) onView(withId(R.id.navigationDrawer)).swipeUp() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + + if (isElementaryStudent) { + assertElementaryNavigationBehaviorMenuItems() + } + else { + assertDefaultNavigationBehaviorMenuItems() + } + } + + private fun assertDefaultNavigationBehaviorMenuItems() { + if(CanvasTest.isLandscapeDevice()) onView(withId(R.id.navigationDrawer)).swipeDown() + files.assertDisplayed() + bookmarks.assertDisplayed() + settings.assertDisplayed() + + if(CanvasTest.isLandscapeDevice()) onView(withId(R.id.navigationDrawer)).swipeUp() + showGrades.assertDisplayed() + colorOverlay.assertDisplayed() + + help.assertDisplayed() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + } + + private fun assertElementaryNavigationBehaviorMenuItems() { + bookmarks.assertNotDisplayed() + showGrades.assertNotDisplayed() + colorOverlay.assertNotDisplayed() + + files.assertDisplayed() + settings.assertDisplayed() + help.assertDisplayed() + changeUser.assertDisplayed() + logoutButton.assertDisplayed() + } + /** * Custom ViewAction to set a SwitchCompat to the desired on/off position * [position]: true -> "on", false -> "off" diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt index 619dfe30d6..0dc2cd51cd 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/ModulesPage.kt @@ -16,10 +16,10 @@ */ package com.instructure.student.ui.pages -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.PerformException import androidx.test.espresso.action.ViewActions.swipeDown +import androidx.test.espresso.action.ViewActions.swipeUp import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* @@ -29,11 +29,8 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.ModuleObject import com.instructure.dataseeding.model.ModuleApiModel -import com.instructure.espresso.assertDisplayed -import com.instructure.espresso.click -import com.instructure.espresso.page.BasePage -import com.instructure.espresso.page.withAncestor -import com.instructure.espresso.scrollTo +import com.instructure.espresso.* +import com.instructure.espresso.page.* import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import org.hamcrest.Matchers.allOf @@ -62,8 +59,8 @@ class ModulesPage : BasePage(R.id.modulesPage) { // Asserts that an assignment (presumably from a module) is locked fun assertAssignmentLocked(assignment: Assignment, course: Course) { val matcher = allOf( - hasSibling(withText(assignment.name)), - withId(R.id.indicator) + hasSibling(withText(assignment.name)), + withId(R.id.indicator) ) // Scroll to the assignment @@ -90,34 +87,47 @@ class ModulesPage : BasePage(R.id.modulesPage) { onView(withText(itemTitle)).check(doesNotExist()) } + fun assertPossiblePointsDisplayed(points: String) { + val matcher = withId(R.id.points) + withText("$points pts") + + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertDisplayed() + } + + fun assertPossiblePointsNotDisplayed(name: String) { + val matcher = withParent(hasSibling(withChild(withId(R.id.title) + withText(name)))) + withId(R.id.points) + + scrollRecyclerView(R.id.listView, matcher) + onView(matcher).assertNotDisplayed() + } + /** * It is occasionally the case that we need to click a few extra buttons to get "fully" into * the item. Thus the [extraClickIds] vararg param. */ fun clickModuleItem(module: ModuleObject, itemTitle: String, vararg extraClickIds: Int) { assertAndClickModuleItem(module.name!!, itemTitle, true) - for(extraClickId in extraClickIds) { + for (extraClickId in extraClickIds) { onView(allOf(withId(extraClickId), isDisplayed())).click() } } // Assert that a module item is displayed and, optionally, click it - private fun assertAndClickModuleItem(moduleName: String, itemTitle: String, clickItem: Boolean = false) { + fun assertAndClickModuleItem(moduleName: String, itemTitle: String, clickItem: Boolean = false) { try { scrollRecyclerView(R.id.listView, withText(itemTitle)) - if(clickItem) { + if (clickItem) { onView(withText(itemTitle)).click() } - } - catch(ex: Exception) { - when(ex) { + } catch (ex: Exception) { + when (ex) { is NoMatchingViewException, is PerformException -> { // Maybe our module hasn't been expanded. Click the module and try again. val moduleMatcher = withText(moduleName) scrollRecyclerView(R.id.listView, moduleMatcher) onView(moduleMatcher).click() scrollRecyclerView(R.id.listView, withText(itemTitle)) - if(clickItem) { + if (clickItem) { onView(withText(itemTitle)).click() } } @@ -131,6 +141,14 @@ class ModulesPage : BasePage(R.id.modulesPage) { } fun refresh() { - onView(allOf(withId(R.id.swipeRefreshLayout),isDisplayed())).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(5))) + onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())).perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(5))) + } + + fun clickOnModuleExpandCollapseIcon(moduleName: String) { + onView(withId(R.id.expandCollapse) + hasSibling(withChild(withText(moduleName) + withId(R.id.title)))).click() + } + + fun assertModulesAndItemsCount(expectedCount: Int) { + onView(withId(R.id.listView) + withDescendant(withId(R.id.title))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount)) } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt index 5bd887a0cb..6801a10cf7 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NewMessagePage.kt @@ -24,14 +24,30 @@ import androidx.test.espresso.ViewAssertion import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.matcher.ViewMatchers.hasChildCount +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.withChild +import androidx.test.espresso.matcher.ViewMatchers.withText import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GroupApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withDescendant +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo +import com.instructure.espresso.typeText import com.instructure.student.R import junit.framework.Assert.assertTrue import org.hamcrest.Matchers.allOf @@ -130,7 +146,7 @@ class NewMessagePage : BasePage() { fun setMessage(messageText: String) { Espresso.closeSoftKeyboard() - onView(allOf(withId(R.id.message), hasSibling(withId(R.id.sendIndividualDivider)))) + onView(allOf(withId(R.id.message), withAncestor(R.id.messageContainer))) .scrollTo() .typeText(messageText) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt index 3b6c8a3b02..f189dec280 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/NotificationPage.kt @@ -22,16 +22,12 @@ import androidx.test.espresso.matcher.ViewMatchers.hasSibling import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.refresh import com.instructure.canvas.espresso.scrollRecyclerView -import com.instructure.espresso.RecyclerViewItemCountGreaterThanAssertion -import com.instructure.espresso.assertDisplayed -import com.instructure.espresso.click +import com.instructure.espresso.* import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText -import com.instructure.espresso.scrollTo -import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.CoreMatchers.allOf import org.hamcrest.Matchers @@ -42,11 +38,20 @@ class NotificationPage : BasePage() { val matcher = withText(title) scrollRecyclerView(R.id.listView, matcher) onView(matcher).assertDisplayed() - } fun assertHasGrade(title: String, grade: String) { - val matcher = allOf(withText(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade: $grade"))) + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade: $grade"))) + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertGradeUpdated(title: String) { + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Grade updated"))) + onView(matcher).scrollTo().assertDisplayed() + } + + fun assertExcused(title: String) { + val matcher = allOf(containsTextCaseInsensitive(title.dropLast(1)) + hasSibling(withId(R.id.description) + withText("Excused"))) onView(matcher).scrollTo().assertDisplayed() } @@ -59,15 +64,14 @@ class NotificationPage : BasePage() { fun assertNotificationWithPoll(title: String, times: Int, pollIntervalSeconds: Long) { var iteration = 0 while (iteration < times) { - Thread.sleep(pollIntervalSeconds*1000) + Thread.sleep(pollIntervalSeconds * 1000) try { val words = title.split(" ") onView(containsTextCaseInsensitive(words[0] + " " + words[1] + " " + words[2])).assertDisplayed() - } catch(e: NoMatchingViewException) { + } catch (e: NoMatchingViewException) { iteration++ refresh() } - } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt index b8f938bf65..430678fc1f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PageListPage.kt @@ -17,22 +17,28 @@ package com.instructure.student.ui.pages import android.view.View -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvasapi2.models.Page import com.instructure.dataseeding.model.PageApiModel +import com.instructure.espresso.DoesNotExistAssertion +import com.instructure.espresso.RecyclerViewItemCountAssertion +import com.instructure.espresso.Searchable import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.waitForCheck import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf -class PageListPage : BasePage(R.id.pageListPage) { +class PageListPage(val searchable: Searchable) : BasePage(R.id.pageListPage) { fun assertFrontPageDisplayed(page: PageApiModel) { val matcher = getFrontPageMatcher(page) @@ -96,9 +102,15 @@ class PageListPage : BasePage(R.id.pageListPage) { fun assertPageNotDisplayed(page: PageApiModel) { // Check for front page - onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(doesNotExist()) + onView(allOf(withId(R.id.homeSubLabel), withText(page.title))).check(DoesNotExistAssertion(10)) // Check for regular page - onView(allOf(withId(R.id.title), withText(page.title))).check(doesNotExist()) + onView(allOf(withId(R.id.title), withText(page.title))).check(DoesNotExistAssertion(10)) + } + + fun assertPageListItemCount(expectedCount: Int) { + onView(allOf(withId(R.id.listView) + + ViewMatchers.withParent(withId(R.id.swipeRefreshLayout)) + + withAncestor(withId(R.id.pageListPage)))).waitForCheck(RecyclerViewItemCountAssertion(expectedCount)) } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt index 502dbf902b..5c6c163386 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/PeopleListPage.kt @@ -18,16 +18,24 @@ package com.instructure.student.ui.pages import android.view.View import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.hasSibling +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.instructure.canvas.espresso.getViewChildCountWithoutId import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.espresso.OnViewWithId import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click -import com.instructure.espresso.page.* +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.withAncestor +import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withText import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf @@ -58,8 +66,9 @@ class PeopleListPage: BasePage(R.id.peopleListPage) { onView(matcher).assertDisplayed() } - fun assertPeopleCount(count: Int) { - onView(withId(R.id.listView) + withAncestor(R.id.peopleListPage)).check(ViewAssertions.matches(hasChildCount(count))) + fun assertPeopleCount(expectedPeopleCount: Int) { + val peopleCount = getViewChildCountWithoutId(allOf(withId(R.id.listView) + withAncestor(R.id.peopleListPage))) + assert(peopleCount == expectedPeopleCount) } fun assertPersonListed(person: User) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt index c8425d2679..fab2457607 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/QuizListPage.kt @@ -17,26 +17,28 @@ package com.instructure.student.ui.pages import android.view.View -import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.swipeDown import androidx.test.espresso.assertion.ViewAssertions.doesNotExist -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints import com.instructure.canvasapi2.models.Quiz import com.instructure.dataseeding.model.QuizApiModel +import com.instructure.espresso.RecyclerViewItemCountAssertion import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed import com.instructure.espresso.click -import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.* import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf class QuizListPage : BasePage(R.id.quizListPage) { + + fun assertNoQuizDisplayed() { + onView(allOf(withId(R.id.emptyView), isDisplayed())).assertDisplayed() + } + fun assertQuizDisplayed(quiz: QuizApiModel) { assertMatcherDisplayed(allOf(withId(R.id.title), withText(quiz.title))) } @@ -45,6 +47,10 @@ class QuizListPage : BasePage(R.id.quizListPage) { assertMatcherDisplayed(allOf(withId(R.id.title), withText(quiz.title))) } + fun assertQuizItemCount(count: Int) { + onView(withId(R.id.listView) + withAncestor(R.id.quizListPage)).check(RecyclerViewItemCountAssertion(count + 1)) + } + fun selectQuiz(quiz: QuizApiModel) { clickMatcher(allOf(withId(R.id.title), withText(quiz.title))) } @@ -53,6 +59,23 @@ class QuizListPage : BasePage(R.id.quizListPage) { clickMatcher(allOf(withId(R.id.title), withText(quiz.title))) } + fun assertQuizNotDisplayed(quiz: QuizApiModel) { + onView(withText(quiz.title)).check(doesNotExist()) + } + + fun refresh() { + onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())) + .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) + } + + fun assertPointsDisplayed(points: String?) { + assertMatcherDisplayed(allOf(withId(R.id.points), withText(points))) + } + + fun assertPointsNotDisplayed() { + onView(withId(R.id.points)).assertNotDisplayed() + } + private fun clickMatcher(matcher: Matcher) { scrollRecyclerView(R.id.listView, matcher) onView(matcher).click() @@ -62,13 +85,4 @@ class QuizListPage : BasePage(R.id.quizListPage) { scrollRecyclerView(R.id.listView, matcher) onView(matcher).assertDisplayed() } - - fun assertQuizNotDisplayed(quiz: QuizApiModel) { - onView(withText(quiz.title)).check(doesNotExist()) - } - - fun refresh() { - onView(allOf(withId(R.id.swipeRefreshLayout), isDisplayed())) - .perform(withCustomConstraints(swipeDown(), isDisplayingAtLeast(10))) - } -} \ No newline at end of file +} diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/SyllabusPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/SyllabusPage.kt index e0b55f5c17..df6acc3af8 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/SyllabusPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/SyllabusPage.kt @@ -17,15 +17,23 @@ package com.instructure.student.ui.pages import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.web.assertion.WebViewAssertions +import androidx.test.espresso.web.sugar.Web +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.Locator import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.plus import com.instructure.espresso.page.withAncestor import com.instructure.espresso.swipeDown import com.instructure.student.R +import org.hamcrest.Matchers import org.hamcrest.Matchers.allOf open class SyllabusPage : BasePage(R.id.syllabusPage) { @@ -39,7 +47,11 @@ open class SyllabusPage : BasePage(R.id.syllabusPage) { } fun selectSummaryTab() { - onView(containsTextCaseInsensitive("summary")).click() + onView(containsTextCaseInsensitive("summary") + withAncestor(R.id.syllabusTabLayout)).click() + } + + fun selectSyllabusTab() { + onView(containsTextCaseInsensitive("syllabus") + withAncestor(R.id.syllabusTabLayout)).click() } fun selectSummaryEvent(name: String) { @@ -50,4 +62,18 @@ open class SyllabusPage : BasePage(R.id.syllabusPage) { onView(allOf(withId(R.id.swipeRefreshLayout), withAncestor(R.id.syllabusPage))).swipeDown() } + fun assertNoTabs() { + onView(withId(R.id.syllabusTabLayout)).check(ViewAssertions.matches(ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.GONE))) + } + + fun assertSyllabusBody(syllabusBody: String) { + Web.onWebView(withId(R.id.contentWebView) + withAncestor(R.id.syllabusPage)) + .withElement(DriverAtoms.findElement(Locator.ID, "content")) + .check( + WebViewAssertions.webMatches( + DriverAtoms.getText(), + Matchers.comparesEqualTo(syllabusBody) + ) + ) + } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt index dfe16866b1..656df2e5e9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/TodoPage.kt @@ -98,7 +98,7 @@ class TodoPage: BasePage(R.id.todoPage) { fun chooseFavoriteCourseFilter() { onView(withId(R.id.todoListFilter)).click() onView(withText(R.string.favoritedCoursesLabel) + withParent(R.id.select_dialog_listview)).click() - onView(withText(R.string.ok)).click() + onView(withText(android.R.string.ok)).click() } fun clearFilter() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 060c624c4b..6000d1db3b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -17,25 +17,91 @@ package com.instructure.student.ui.utils import android.app.Activity +import android.app.Instrumentation +import android.content.Intent +import android.net.Uri import android.os.Environment import android.util.Log import android.view.View +import androidx.core.content.FileProvider import androidx.hilt.work.HiltWorkerFactory import androidx.test.espresso.Espresso import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry import com.instructure.canvas.espresso.CanvasTest import com.instructure.espresso.InstructureActivityTestRule +import com.instructure.espresso.Searchable import com.instructure.espresso.swipeRight +import com.instructure.pandautils.utils.Const import com.instructure.student.BuildConfig import com.instructure.student.R import com.instructure.student.activity.LoginActivity import com.instructure.student.espresso.StudentHiltTestApplication_Application -import com.instructure.student.ui.pages.* +import com.instructure.student.ui.pages.AboutPage +import com.instructure.student.ui.pages.AnnotationCommentListPage +import com.instructure.student.ui.pages.AnnouncementListPage +import com.instructure.student.ui.pages.AssignmentDetailsPage +import com.instructure.student.ui.pages.AssignmentListPage +import com.instructure.student.ui.pages.BookmarkPage +import com.instructure.student.ui.pages.CalendarEventPage +import com.instructure.student.ui.pages.CanvasWebViewPage +import com.instructure.student.ui.pages.ConferenceDetailsPage +import com.instructure.student.ui.pages.ConferenceListPage +import com.instructure.student.ui.pages.CourseBrowserPage +import com.instructure.student.ui.pages.CourseGradesPage +import com.instructure.student.ui.pages.DashboardPage +import com.instructure.student.ui.pages.DiscussionDetailsPage +import com.instructure.student.ui.pages.DiscussionListPage +import com.instructure.student.ui.pages.EditDashboardPage +import com.instructure.student.ui.pages.ElementaryCoursePage +import com.instructure.student.ui.pages.ElementaryDashboardPage +import com.instructure.student.ui.pages.FileListPage +import com.instructure.student.ui.pages.FileUploadPage +import com.instructure.student.ui.pages.GradesPage +import com.instructure.student.ui.pages.GroupBrowserPage +import com.instructure.student.ui.pages.HelpPage +import com.instructure.student.ui.pages.HomeroomPage +import com.instructure.student.ui.pages.ImportantDatesPage +import com.instructure.student.ui.pages.InboxConversationPage +import com.instructure.student.ui.pages.InboxPage +import com.instructure.student.ui.pages.LeftSideNavigationDrawerPage +import com.instructure.student.ui.pages.LegalPage +import com.instructure.student.ui.pages.LoginFindSchoolPage +import com.instructure.student.ui.pages.LoginLandingPage +import com.instructure.student.ui.pages.LoginSignInPage +import com.instructure.student.ui.pages.ModuleProgressionPage +import com.instructure.student.ui.pages.ModulesPage +import com.instructure.student.ui.pages.NewMessagePage +import com.instructure.student.ui.pages.NotificationPage +import com.instructure.student.ui.pages.PageListPage +import com.instructure.student.ui.pages.PairObserverPage +import com.instructure.student.ui.pages.PandaAvatarPage +import com.instructure.student.ui.pages.PeopleListPage +import com.instructure.student.ui.pages.PersonDetailsPage +import com.instructure.student.ui.pages.PickerSubmissionUploadPage +import com.instructure.student.ui.pages.ProfileSettingsPage +import com.instructure.student.ui.pages.QRLoginPage +import com.instructure.student.ui.pages.QuizListPage +import com.instructure.student.ui.pages.QuizTakingPage +import com.instructure.student.ui.pages.RemoteConfigSettingsPage +import com.instructure.student.ui.pages.ResourcesPage +import com.instructure.student.ui.pages.SchedulePage +import com.instructure.student.ui.pages.SettingsPage +import com.instructure.student.ui.pages.ShareExtensionStatusPage +import com.instructure.student.ui.pages.ShareExtensionTargetPage +import com.instructure.student.ui.pages.SubmissionDetailsPage +import com.instructure.student.ui.pages.SyllabusPage +import com.instructure.student.ui.pages.TextSubmissionUploadPage +import com.instructure.student.ui.pages.TodoPage +import com.instructure.student.ui.pages.UrlSubmissionUploadPage import dagger.hilt.android.testing.HiltAndroidRule import instructure.rceditor.RCETextEditor import org.hamcrest.Matcher +import org.hamcrest.core.AllOf import org.junit.Before import org.junit.Rule import java.io.File @@ -77,13 +143,14 @@ abstract class StudentTest : CanvasTest() { * Required for auto complete of page objects within tests */ val annotationCommentListPage = AnnotationCommentListPage() - val announcementListPage = AnnouncementListPage() + val announcementListPage = AnnouncementListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val assignmentDetailsPage = AssignmentDetailsPage() - val assignmentListPage = AssignmentListPage() + val assignmentListPage = AssignmentListPage(Searchable(R.id.search, R.id.search_src_text)) val bookmarkPage = BookmarkPage() val calendarEventPage = CalendarEventPage() val canvasWebViewPage = CanvasWebViewPage() val courseBrowserPage = CourseBrowserPage() + val groupBrowserPage = GroupBrowserPage() val conferenceListPage = ConferenceListPage() val conferenceDetailsPage = ConferenceDetailsPage() val elementaryCoursePage = ElementaryCoursePage() @@ -91,9 +158,9 @@ abstract class StudentTest : CanvasTest() { val dashboardPage = DashboardPage() val leftSideNavigationDrawerPage = LeftSideNavigationDrawerPage() val discussionDetailsPage = DiscussionDetailsPage() - val discussionListPage = DiscussionListPage() + val discussionListPage = DiscussionListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val editDashboardPage = EditDashboardPage() - val fileListPage = FileListPage() + val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) val fileUploadPage = FileUploadPage() val helpPage = HelpPage() val inboxConversationPage = InboxConversationPage() @@ -107,7 +174,7 @@ abstract class StudentTest : CanvasTest() { val modulesPage = ModulesPage() val newMessagePage = NewMessagePage() val notificationPage = NotificationPage() - val pageListPage = PageListPage() + val pageListPage = PageListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) val pairObserverPage = PairObserverPage() val pandaAvatarPage = PandaAvatarPage() val peopleListPage = PeopleListPage() @@ -149,6 +216,40 @@ abstract class StudentTest : CanvasTest() { return 0 } } + + fun setupFileOnDevice(fileName: String): Uri { + File(InstrumentationRegistry.getInstrumentation().targetContext.cacheDir, "file_upload").deleteRecursively() + copyAssetFileToExternalCache(activityRule.activity, fileName) + + val dir = activityRule.activity.externalCacheDir + val file = File(dir?.path, fileName) + + val instrumentationContext = InstrumentationRegistry.getInstrumentation().context + return FileProvider.getUriForFile( + instrumentationContext, + "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, + file + ) + } + + fun stubFilePickerIntent(fileName: String) { + val resultData = Intent() + val dir = activityRule.activity.externalCacheDir + val file = File(dir?.path, fileName) + val newFileUri = FileProvider.getUriForFile( + activityRule.activity, + "com.instructure.candroid" + Const.FILE_PROVIDER_AUTHORITY, + file + ) + resultData.data = newFileUri + + Intents.intending( + AllOf.allOf( + IntentMatchers.hasAction(Intent.ACTION_GET_CONTENT), + IntentMatchers.hasType("*/*"), + ) + ).respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData)) + } } /* diff --git a/apps/student/src/main/AndroidManifest.xml b/apps/student/src/main/AndroidManifest.xml index 189c333817..064bc4dabb 100644 --- a/apps/student/src/main/AndroidManifest.xml +++ b/apps/student/src/main/AndroidManifest.xml @@ -277,10 +277,6 @@ android:name="com.instructure.pandautils.services.NotoriousUploadService" android:exported="false" /> - - diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index e1d8e8cfa3..f665f6802c 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -45,6 +45,7 @@ import androidx.core.view.GravityCompat import androidx.core.view.MenuItemCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager +import androidx.lifecycle.lifecycleScope import com.airbnb.lottie.LottieAnimationView import com.google.android.material.bottomnavigation.BottomNavigationView import com.instructure.canvasapi2.CanvasRestAdapter @@ -130,6 +131,9 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. @Inject lateinit var updateManager: UpdateManager + @Inject + lateinit var featureFlagProvider: FeatureFlagProvider + private var routeJob: WeaveJob? = null private var debounceJob: Job? = null private var drawerItemSelectedJob: Job? = null @@ -266,6 +270,8 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. setupNavDrawerItems() + loadFeatureFlags() + checkAppUpdates() val savedBottomScreens = savedInstanceState?.getStringArrayList(BOTTOM_SCREENS_BUNDLE_KEY) @@ -280,6 +286,12 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. requestNotificationsPermission() } + private fun loadFeatureFlags() { + lifecycleScope.launch { + featureFlagProvider.fetchEnvironmentFeatureFlags() + } + } + private fun requestNotificationsPermission() { if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { notificationsPermissionContract.launch(Manifest.permission.POST_NOTIFICATIONS) diff --git a/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt index 24aca23113..8897e148d8 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/FileListRecyclerAdapter.kt @@ -25,7 +25,6 @@ import com.instructure.canvasapi2.utils.weave.WeaveJob import com.instructure.canvasapi2.utils.weave.awaitPaginated import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.fragment.FileListFragment import com.instructure.student.holders.FileViewHolder @@ -81,7 +80,7 @@ open class FileListRecyclerAdapter( apiCall = tryWeave { // Check if the folder is marked as stale (i.e. items were added/changed/removed) - val isStale = StudentPrefs.staleFolderIds.contains(folder.id) == true + val isStale = StudentPrefs.staleFolderIds.contains(folder.id) // Force network for pull-to-refresh and stale folders val forceNetwork = isRefresh || isStale diff --git a/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt index 4822ce62fa..9dca6b4615 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/GradesListRecyclerAdapter.kt @@ -34,6 +34,7 @@ import com.instructure.canvasapi2.models.CourseGrade import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.GradingPeriod import com.instructure.canvasapi2.models.GradingPeriodResponse +import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.isNullOrEmpty @@ -90,7 +91,7 @@ open class GradesListRecyclerAdapter( interface AdapterToGradesCallback { val isEdit: Boolean - fun notifyGradeChanged(courseGrade: CourseGrade?) + fun notifyGradeChanged(courseGrade: CourseGrade?, restrictQuantitativeData: Boolean, gradingScheme: List) fun setTermSpinnerState(isEnabled: Boolean) fun setIsWhatIfGrading(isWhatIfGrading: Boolean) } @@ -205,7 +206,8 @@ open class GradesListRecyclerAdapter( course.enrollments?.find { it.userId == student.id }?.let { course.enrollments = mutableListOf(it) courseGrade = course.getCourseGradeFromEnrollment(it, false) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData, course.gradingScheme) } } } catch (e: CancellationException) { @@ -272,7 +274,8 @@ open class GradesListRecyclerAdapter( if (enrollment.isStudent && enrollment.userId == ApiPrefs.user!!.id) { val course = canvasContext as Course? courseGrade = course!!.getCourseGradeForGradingPeriodSpecificEnrollment(enrollment = enrollment) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData, course.gradingScheme) // We need to update the course that the fragment is using course.addEnrollment(enrollment) } @@ -281,8 +284,11 @@ open class GradesListRecyclerAdapter( private fun updateCourseGrade() { // All grading periods and no grading periods are the same case - courseGrade = (canvasContext as Course).getCourseGrade(true) - adapterToGradesCallback?.notifyGradeChanged(courseGrade) + val course = canvasContext as? Course + courseGrade = course?.getCourseGrade(true) + val restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?: false + val gradingScheme = course?.gradingScheme ?: emptyList() + adapterToGradesCallback?.notifyGradeChanged(courseGrade, restrictQuantitativeData, gradingScheme) } private fun updateWithAllAssignments(forceNetwork: Boolean) { @@ -322,7 +328,8 @@ open class GradesListRecyclerAdapter( isAllPagesLoaded = true // We want to disable what if grading if MGP weights are enabled, or assignment groups are enabled - if ((canvasContext as Course).isWeightedGradingPeriods || hasValidGroupRule) { + val course = (canvasContext as Course) + if (course.isWeightedGradingPeriods || hasValidGroupRule || course.settings?.restrictQuantitativeData == true) { adapterToGradesCallback?.setIsWhatIfGrading(false) } else { adapterToGradesCallback?.setIsWhatIfGrading(true) diff --git a/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt index cc20e5292d..ca0bc80e2f 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/ModuleListRecyclerAdapter.kt @@ -28,6 +28,7 @@ import android.view.WindowManager import android.widget.ProgressBar import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.ModuleManager import com.instructure.canvasapi2.managers.TabManager import com.instructure.canvasapi2.models.* @@ -41,8 +42,8 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandarecycler.interfaces.ViewHolderHeaderClicked import com.instructure.pandarecycler.util.GroupSortedList import com.instructure.pandarecycler.util.Types -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Utils +import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.holders.ModuleEmptyViewHolder @@ -66,7 +67,9 @@ open class ModuleListRecyclerAdapter( private val mModuleItemCallbacks = HashMap() private var mModuleObjectCallback: StatusCallback>? = null - private var checkCourseTabsJob: Job? = null + private var getInitialDataJob: Job? = null + + private var courseSettings: CourseSettings? = null /* For testing purposes only */ protected constructor(context: Context) : this(CanvasContext.defaultCanvasContext(), context, false,null) // Callback not needed for testing, cast to null @@ -108,8 +111,10 @@ open class ModuleListRecyclerAdapter( val groupItemCount = getGroupItemCount(moduleObject) val itemPosition = storedIndexOfItem(moduleObject, moduleItem) - (holder as ModuleViewHolder).bind(moduleObject, moduleItem, context, adapterToFragmentCallback, courseColor, - itemPosition == 0, itemPosition == groupItemCount - 1) + (holder as ModuleViewHolder).bind( + moduleObject, moduleItem, context, adapterToFragmentCallback, courseColor, itemPosition == 0, + itemPosition == groupItemCount - 1, courseSettings?.restrictQuantitativeData.orDefault() + ) } } @@ -140,7 +145,7 @@ open class ModuleListRecyclerAdapter( override fun refresh() { shouldExhaustPagination = false mModuleItemCallbacks.clear() - checkCourseTabsJob?.cancel() + getInitialDataJob?.cancel() collapseAll() super.refresh() } @@ -148,7 +153,7 @@ open class ModuleListRecyclerAdapter( override fun cancel() { mModuleItemCallbacks.values.forEach { it.cancel() } mModuleObjectCallback?.cancel() - checkCourseTabsJob?.cancel() + getInitialDataJob?.cancel() } override fun contextReady() { @@ -355,9 +360,11 @@ open class ModuleListRecyclerAdapter( } override fun loadFirstPage() { - checkCourseTabsJob = tryWeave { - val tabs = awaitApi> { TabManager.getTabs(courseContext, it, isRefresh) } - .filter { !(it.isExternal && it.isHidden) } + getInitialDataJob = tryWeave { + val tabs = awaitApi { TabManager.getTabs(courseContext, it, isRefresh) } + .filter { !(it.isExternal && it.isHidden) } + + courseSettings = CourseManager.getCourseSettingsAsync(courseContext.id, isRefresh).await().dataOrNull // We only want to show modules if its a course nav option OR set to as the homepage if (tabs.find { it.tabId == "modules" } != null || (courseContext as Course).homePage?.apiString == "modules") { diff --git a/apps/student/src/main/java/com/instructure/student/adapter/NotificationListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/NotificationListRecyclerAdapter.kt index ff1cf533e7..8b09f7f7b5 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/NotificationListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/NotificationListRecyclerAdapter.kt @@ -21,7 +21,7 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.managers.CourseManager.createCourseMap -import com.instructure.canvasapi2.managers.CourseManager.getCourses +import com.instructure.canvasapi2.managers.CourseManager.getCoursesWithGradingScheme import com.instructure.canvasapi2.managers.GroupManager.createGroupMap import com.instructure.canvasapi2.managers.GroupManager.getAllGroups import com.instructure.canvasapi2.managers.InboxManager.getConversation @@ -112,7 +112,7 @@ class NotificationListRecyclerAdapter( override val isPaginated get() = true override fun loadFirstPage() { - getCourses(true, coursesCallback) + getCoursesWithGradingScheme(true, coursesCallback) getAllGroups(groupsCallback, true) if (canvasContext.type == CanvasContext.Type.USER) { getUserStream(streamCallback, true) diff --git a/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt index 9147cd8bf0..02cf7b554c 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/QuizListRecyclerAdapter.kt @@ -20,8 +20,10 @@ package com.instructure.student.adapter import android.content.Context import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.QuizManager import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Quiz import com.instructure.canvasapi2.utils.ApiType import com.instructure.canvasapi2.utils.filterWithQuery @@ -32,6 +34,7 @@ import com.instructure.canvasapi2.utils.weave.tryWeave import com.instructure.pandarecycler.interfaces.ViewHolderHeaderClicked import com.instructure.pandarecycler.util.GroupSortedList import com.instructure.pandarecycler.util.Types +import com.instructure.pandautils.utils.orDefault import com.instructure.pandautils.utils.textAndIconColor import com.instructure.pandautils.utils.toast import com.instructure.student.R @@ -49,6 +52,8 @@ class QuizListRecyclerAdapter( private var apiCall: WeaveJob? = null + private var settings: CourseSettings? = null + var searchQuery = "" set(value) { field = value @@ -94,7 +99,8 @@ class QuizListRecyclerAdapter( apiCall = tryWeave { val refreshing = isRefresh val newQuizzes = mutableListOf() - awaitPaginated> { + settings = CourseManager.getCourseSettingsAsync(canvasContext.id, refreshing).await().dataOrNull + awaitPaginated { exhaustive = true onRequestFirst { QuizManager.getFirstPageQuizList(canvasContext, refreshing, it) } onRequestNext { url, callback -> QuizManager.getNextPageQuizList(url, refreshing, callback) } @@ -120,7 +126,8 @@ class QuizListRecyclerAdapter( } override fun onBindChildHolder(holder: RecyclerView.ViewHolder, s: String, quiz: Quiz) { - (holder as? QuizViewHolder)?.bind(quiz, adapterToFragmentCallback, context, canvasContext.textAndIconColor) + val restrictQuantitativeData = settings?.restrictQuantitativeData.orDefault() + (holder as? QuizViewHolder)?.bind(quiz, adapterToFragmentCallback, context, canvasContext.textAndIconColor, restrictQuantitativeData) } override fun onBindHeaderHolder(holder: RecyclerView.ViewHolder, s: String, isExpanded: Boolean) { diff --git a/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt index 4102e566ac..ff503444bc 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/assignment/AssignmentListRecyclerAdapter.kt @@ -58,7 +58,10 @@ abstract class AssignmentListRecyclerAdapter ( private var assignmentGroupCallback: StatusCallback>? = null override var currentGradingPeriod: GradingPeriod? = null private var apiJob: WeaveJob? = null + private var courseJob: WeaveJob? = null protected var assignmentGroups: List = emptyList() + private var restrictQuantitativeData = false + private var gradingSchemes = listOf() var filter: AssignmentListFilter = AssignmentListFilter.ALL set(value) { @@ -130,9 +133,20 @@ abstract class AssignmentListRecyclerAdapter ( if changes are made here, check if they are needed in the other recycler adapters.*/ val course = canvasContext as Course + courseJob = tryWeave { + val detailedCourse = CourseManager.getCourseWithGradeAsync(canvasContext.id, isRefresh).await().dataOrNull + restrictQuantitativeData = detailedCourse?.settings?.restrictQuantitativeData ?: false + gradingSchemes = detailedCourse?.gradingScheme ?: emptyList() + loadAssignmentsData(course) + } catch { + loadAssignmentsData(course) + } + } + + private fun loadAssignmentsData(course: Course) { //This check is for the "all grading periods" option if (currentGradingPeriod != null && currentGradingPeriod!!.title != null - && currentGradingPeriod!!.title == context.getString(R.string.assignmentsListAllGradingPeriods)) { + && currentGradingPeriod!!.title == context.getString(R.string.assignmentsListAllGradingPeriods)) { loadAssignment() return } @@ -185,7 +199,7 @@ abstract class AssignmentListRecyclerAdapter ( assignmentGroup: AssignmentGroup, assignment: Assignment ) { - (holder as AssignmentViewHolder).bind(context, assignment, canvasContext.backgroundColor, adapterToAssignmentsCallback) + (holder as AssignmentViewHolder).bind(context, assignment, canvasContext.backgroundColor, adapterToAssignmentsCallback, restrictQuantitativeData, gradingSchemes) } override fun onBindEmptyHolder(holder: RecyclerView.ViewHolder, assignmentGroup: AssignmentGroup) { @@ -243,6 +257,7 @@ abstract class AssignmentListRecyclerAdapter ( override fun cancel() { super.cancel() apiJob?.cancel() + courseJob?.cancel() } } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt index f6c9d61ccd..f9c07cd306 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/AssignmentDetailsViewModel.kt @@ -91,6 +91,8 @@ class AssignmentDetailsViewModel @Inject constructor( private var dbSubmission: DatabaseSubmission? = null private var isUploading = false + private var restrictQuantitativeData = false + private var gradingScheme = emptyList() var assignment: Assignment? = null private set @@ -163,6 +165,8 @@ class AssignmentDetailsViewModel @Inject constructor( viewModelScope.launch { try { val courseResult = courseManager.getCourseWithGradeAsync(course?.id.orDefault(), forceNetwork).await().dataOrThrow + restrictQuantitativeData = courseResult.settings?.restrictQuantitativeData ?: false + gradingScheme = courseResult.gradingScheme isObserver = courseResult.enrollments?.firstOrNull { it.isObserver } != null @@ -236,11 +240,15 @@ class AssignmentDetailsViewModel @Inject constructor( @Suppress("DEPRECATION") private suspend fun getViewData(assignment: Assignment, hasDraft: Boolean): AssignmentDetailsViewData { - val points = resources.getQuantityString( - R.plurals.quantityPointsAbbreviated, - assignment.pointsPossible.toInt(), - NumberHelper.formatDecimal(assignment.pointsPossible, 1, true) - ) + val points = if (restrictQuantitativeData) { + "" + } else { + resources.getQuantityString( + R.plurals.quantityPointsAbbreviated, + assignment.pointsPossible.toInt(), + NumberHelper.formatDecimal(assignment.pointsPossible, 1, true) + ) + } val assignmentState = AssignmentUtils2.getAssignmentState(assignment, assignment.submission, false) @@ -262,11 +270,11 @@ class AssignmentDetailsViewModel @Inject constructor( ) val submissionStatusTint = if (assignment.isSubmitted) { - R.color.backgroundSuccess + R.color.textSuccess } else if (isMissing) { - R.color.backgroundDanger + R.color.textDanger } else { - R.color.backgroundDark + R.color.textDark } val submittedStatusIcon = if (assignment.isSubmitted) R.drawable.ic_complete_solid else R.drawable.ic_no @@ -434,7 +442,9 @@ class AssignmentDetailsViewModel @Inject constructor( resources, colorKeeper.getOrGenerateColor(course), assignment, - assignment.submission + assignment.submission, + restrictQuantitativeData, + gradingScheme = gradingScheme ), dueDate = due, submissionTypes = submissionTypes, @@ -466,8 +476,10 @@ class AssignmentDetailsViewModel @Inject constructor( colorKeeper.getOrGenerateColor(course), assignment, selectedSubmission, + restrictQuantitativeData, attempt?.isUploading.orDefault(), - attempt?.isFailed.orDefault() + attempt?.isFailed.orDefault(), + gradingScheme ) _data.value?.notifyPropertyChanged(BR.selectedGradeCellViewData) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt index 9d0746a6f2..38d67e8b48 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignmentdetails/gradecellview/GradeCellViewData.kt @@ -3,8 +3,10 @@ package com.instructure.student.features.assignmentdetails.gradecellview import android.content.res.Resources import androidx.core.graphics.ColorUtils import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.NumberHelper +import com.instructure.canvasapi2.utils.convertScoreToLetterGrade import com.instructure.pandautils.utils.ThemedColor import com.instructure.pandautils.utils.getContentDescriptionForMinusGradeString import com.instructure.pandautils.utils.orDefault @@ -43,20 +45,22 @@ data class GradeCellViewData( courseColor: ThemedColor, assignment: Assignment?, submission: Submission?, + restrictQuantitativeData: Boolean = false, uploading: Boolean = false, - failed: Boolean = false + failed: Boolean = false, + gradingScheme: List = emptyList() ): GradeCellViewData { - return if (uploading) { - GradeCellViewData(courseColor, State.UPLOADING) - } else if (failed) { - GradeCellViewData(courseColor, State.FAILED) - } else if ( - assignment == null + val hideGrades = restrictQuantitativeData && assignment?.isGradingTypeQuantitative == true && submission?.excused != true && gradingScheme.isEmpty() + val emptyGradeCell = assignment == null || submission == null || (submission.submittedAt == null && !submission.isGraded) || assignment.gradingType == Assignment.NOT_GRADED_TYPE - ) { - GradeCellViewData( + || hideGrades + + return when { + uploading -> GradeCellViewData(courseColor, State.UPLOADING) + failed -> GradeCellViewData(courseColor, State.FAILED) + emptyGradeCell -> GradeCellViewData( courseColor = courseColor, state = State.EMPTY, gradeCellContentDescription = getContentDescriptionText( @@ -64,8 +68,7 @@ data class GradeCellViewData( resources.getString(R.string.submissionAndRubric) ) ) - } else if (submission.isSubmitted) { - GradeCellViewData( + submission!!.isSubmitted -> GradeCellViewData( courseColor = courseColor, state = State.SUBMITTED, gradeCellContentDescription = getContentDescriptionText( @@ -74,113 +77,143 @@ data class GradeCellViewData( resources.getString(R.string.submissionStatusSuccessSubtitle) ) ) - } else { - val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) - val outOfText = resources.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) - val outOfContentDescriptionText = resources.getString(R.string.outOfPointsFormatted, pointsPossibleText) + else -> createGradedViewData(resources, courseColor, assignment!!, submission, restrictQuantitativeData, gradingScheme) + } + } - if (submission.excused) { - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = 1f, - showCompleteIcon = true, - grade = resources.getString(R.string.excused), - outOf = outOfText, - gradeCellContentDescription = getContentDescriptionText( - resources, - resources.getString(R.string.gradeExcused), - outOfContentDescriptionText - ) + private fun createGradedViewData( + resources: Resources, + courseColor: ThemedColor, + assignment: Assignment, + submission: Submission, + restrictQuantitativeData: Boolean, + gradingScheme: List + ): GradeCellViewData { + val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) + val outOfText = if (restrictQuantitativeData) "" else resources.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) + val outOfContentDescriptionText = if (restrictQuantitativeData) "" else resources.getString(R.string.outOfPointsFormatted, pointsPossibleText) + + return if (submission.excused) { + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1f, + showCompleteIcon = true, + grade = resources.getString(R.string.excused), + outOf = outOfText, + gradeCellContentDescription = getContentDescriptionText( + resources, + resources.getString(R.string.gradeExcused), + outOfContentDescriptionText ) - } else if (assignment.gradingType == Assignment.PASS_FAIL_TYPE) { - val isComplete = (submission.grade == "complete") - val grade = resources.getString(if (isComplete) R.string.gradeComplete else R.string.gradeIncomplete) - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = 1f, - showCompleteIcon = isComplete, - showIncompleteIcon = !isComplete, - grade = grade, - outOf = outOfText, - gradeCellContentDescription = getContentDescriptionText( - resources, - grade, - outOfContentDescriptionText - ) + ) + } else if (assignment.gradingType == Assignment.PASS_FAIL_TYPE) { + val isComplete = (submission.grade == "complete") + val grade = resources.getString(if (isComplete) R.string.gradeComplete else R.string.gradeIncomplete) + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1f, + showCompleteIcon = isComplete, + showIncompleteIcon = !isComplete, + grade = grade, + outOf = outOfText, + gradeCellContentDescription = getContentDescriptionText( + resources, + grade, + outOfContentDescriptionText ) + ) + } else if (restrictQuantitativeData) { + val grade = if (assignment.isGradingTypeQuantitative) { + convertScoreToLetterGrade(submission.score, assignment.pointsPossible, gradingScheme) } else { - val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) - val chartPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() - // If grading type is Points, don't show the grade since we're already showing it as the score - var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" - // Google talkback fails hard on "minus", so we need to remove the dash and replace it with the word - val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) - // We also need the entire grade cell to be read in a reasonable fashion - val gradeCellContentDescription = when { - accessibleGradeString.isNotEmpty() -> resources.getString( - R.string.a11y_gradeCellContentDescriptionWithLetterGrade, - score, - outOfContentDescriptionText, - accessibleGradeString - ) - grade.isNotEmpty() -> resources.getString( - R.string.a11y_gradeCellContentDescriptionWithLetterGrade, - score, - outOfContentDescriptionText, - grade - ) - else -> resources.getString(R.string.a11y_gradeCellContentDescription, score, outOfContentDescriptionText) - } + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) + submission.grade ?: "" + } + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) + val contentDescription = resources.getString( + R.string.a11y_gradeCellContentDescriptionLetterGradeOnly, + accessibleGradeString + ) + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) - var latePenalty = "" - var finalGrade = "" + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = 1.0f, + showCompleteIcon = true, + grade = grade, + gradeCellContentDescription = contentDescription, + ) + } else { + val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) + val chartPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() + // If grading type is Points, don't show the grade since we're already showing it as the score + var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" + // Google talkback fails hard on "minus", so we need to remove the dash and replace it with the word + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, resources) + // We also need the entire grade cell to be read in a reasonable fashion + val gradeCellContentDescription = when { + accessibleGradeString.isNotEmpty() -> resources.getString( + R.string.a11y_gradeCellContentDescriptionWithLetterGrade, + score, + outOfContentDescriptionText, + accessibleGradeString + ) + grade.isNotEmpty() -> resources.getString( + R.string.a11y_gradeCellContentDescriptionWithLetterGrade, + score, + outOfContentDescriptionText, + grade + ) + else -> resources.getString(R.string.a11y_gradeCellContentDescription, score, outOfContentDescriptionText) + } + System.lineSeparator() + resources.getString(R.string.a11y_gradeCellContentDescriptionHint) - // Adjust for late penalty, if any - if (submission.pointsDeducted.orDefault() > 0.0) { - grade = "" // Grade will be shown in the 'final grade' text - val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted.orDefault(), 2, true) - latePenalty = resources.getString(R.string.latePenalty, pointsDeducted) - finalGrade = resources.getString(R.string.finalGradeFormatted, submission.grade) - } + var latePenalty = "" + var finalGrade = "" - val stats = assignment.scoreStatistics?.let { stats -> - GradeCellViewState.GradeStats( - score = submission.score, - outOf = assignment.pointsPossible, - min = stats.min, - max = stats.max, - mean = stats.mean, - minText = resources.getString( - R.string.scoreStatisticsLow, - NumberHelper.formatDecimal(stats.min, 1, true) - ), - maxText = resources.getString( - R.string.scoreStatisticsHigh, - NumberHelper.formatDecimal(stats.max, 1, true) - ), - meanText = resources.getString( - R.string.scoreStatisticsMean, - NumberHelper.formatDecimal(stats.mean, 1, true) - ) - ) - } + // Adjust for late penalty, if any + if (submission.pointsDeducted.orDefault() > 0.0) { + grade = "" // Grade will be shown in the 'final grade' text + val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted.orDefault(), 2, true) + latePenalty = resources.getString(R.string.latePenalty, pointsDeducted) + finalGrade = resources.getString(R.string.finalGradeFormatted, submission.grade) + } - GradeCellViewData( - courseColor = courseColor, - state = State.GRADED, - chartPercent = chartPercent, - showPointsLabel = true, - score = score, - grade = grade, - gradeCellContentDescription = gradeCellContentDescription, - outOf = outOfText, - latePenalty = latePenalty, - finalGrade = finalGrade, - stats = stats + val stats = assignment.scoreStatistics?.let { stats -> + GradeCellViewState.GradeStats( + score = submission.score, + outOf = assignment.pointsPossible, + min = stats.min, + max = stats.max, + mean = stats.mean, + minText = resources.getString( + R.string.scoreStatisticsLow, + NumberHelper.formatDecimal(stats.min, 1, true) + ), + maxText = resources.getString( + R.string.scoreStatisticsHigh, + NumberHelper.formatDecimal(stats.max, 1, true) + ), + meanText = resources.getString( + R.string.scoreStatisticsMean, + NumberHelper.formatDecimal(stats.mean, 1, true) + ) ) } + + GradeCellViewData( + courseColor = courseColor, + state = State.GRADED, + chartPercent = chartPercent, + showPointsLabel = true, + score = score, + grade = grade, + gradeCellContentDescription = gradeCellContentDescription, + outOf = outOfText, + latePenalty = latePenalty, + finalGrade = finalGrade, + stats = stats + ) } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt index 98206784de..88225b69ce 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/CourseModuleProgressionFragment.kt @@ -58,23 +58,22 @@ import com.instructure.student.util.Const import com.instructure.student.util.CourseModulesStore import com.instructure.student.util.ModuleProgressionUtility import com.instructure.student.util.ModuleUtility +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job import kotlinx.coroutines.launch import okhttp3.ResponseBody import retrofit2.Response +import javax.inject.Inject @PageView(url = "courses/{canvasContext}/modules") @ScreenView(SCREEN_VIEW_COURSE_MODULE_PROGRESSION) +@AndroidEntryPoint class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { private val binding by viewBinding(CourseModuleProgressionBinding::bind) - private val discussionRouteHelper = DiscussionRouteHelper( - FeaturesManager, - FeatureFlagProvider(UserManager, ApiPrefs), - DiscussionManager, - GroupManager - ) + @Inject + lateinit var discussionRouteHelper: DiscussionRouteHelper private var routeModuleProgressionJob: Job? = null private var moduleItemsJob: Job? = null @@ -724,7 +723,7 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { val moduleItemAsset = ModuleItemAsset.fromAssetType(assetType) if (moduleItemAsset != ModuleItemAsset.MODULE_ITEM) { val newRoute = route.copy(secondaryClass = moduleItemAsset.routeClass, removePreviousScreen = true) - RouteMatcher.route(requireContext(), newRoute) + RouteMatcher.route(requireActivity(), newRoute) return@tryWeave } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt index add2fbdc0f..f3b43fbb6c 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DiscussionDetailsFragment.kt @@ -31,6 +31,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import com.google.android.material.snackbar.Snackbar import com.instructure.canvasapi2.StatusCallback +import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.DiscussionManager import com.instructure.canvasapi2.managers.DiscussionManager.deleteDiscussionEntry import com.instructure.canvasapi2.managers.GroupManager @@ -97,6 +98,8 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { private var isNestedDetail: Boolean by BooleanArg(default = false, key = IS_NESTED_DETAIL) private val groupDiscussion: Boolean by BooleanArg(default = false, key = GROUP_DISCUSSION) + private var courseSettings: CourseSettings? = null + private var scrollPosition: Int = 0 private var authenticatedSessionURL: String? = null @@ -550,6 +553,16 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { // Do we have a discussion topic header? if not fetch it, or if forceRefresh is true force a fetch + val courseId = when (canvasContext) { + is Course -> canvasContext.id + is Group -> (canvasContext as Group).courseId + else -> null + } + + if (courseId != null) { + courseSettings = CourseManager.getCourseSettingsAsync(courseId, forceRefresh).await().dataOrNull + } + if (forceRefresh) { val discussionTopicHeaderId = if (discussionTopicHeaderId == 0L && discussionTopicHeader.id != 0L) discussionTopicHeader.id else discussionTopicHeaderId if (!updateToGroupIfNecessary()) { @@ -602,9 +615,9 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { delay(300) discussionsScrollView.post { if (topLevelReplyPosted) { - discussionsScrollView?.fullScroll(ScrollView.FOCUS_DOWN) + discussionsScrollView.fullScroll(ScrollView.FOCUS_DOWN) } else { - discussionsScrollView?.scrollTo(0, scrollPosition) + discussionsScrollView.scrollTo(0, scrollPosition) } } } @@ -729,7 +742,7 @@ class DiscussionDetailsFragment : ParentFragment(), Bookmarkable { private fun setupAssignmentDetails(assignment: Assignment) = with(binding) { with(assignment) { - pointsTextView.setVisible() + pointsTextView.setVisible(!courseSettings?.restrictQuantitativeData.orDefault()) // Points possible pointsTextView.text = resources.getQuantityString( R.plurals.quantityPointsAbbreviated, diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt index 2f0382c295..71ec7a8fca 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileDetailsFragment.kt @@ -23,6 +23,7 @@ import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.work.WorkManager import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions import com.instructure.canvasapi2.managers.FileFolderManager @@ -41,20 +42,26 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_FILE_DETAILS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.databinding.FragmentFileDetailsBinding import com.instructure.student.events.ModuleUpdatedEvent import com.instructure.student.events.post -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.util.StringUtilities +import dagger.hilt.android.AndroidEntryPoint import okhttp3.ResponseBody import java.util.* +import javax.inject.Inject @ScreenView(SCREEN_VIEW_FILE_DETAILS) @PageView(url = "{canvasContext}/files/{fileId}") +@AndroidEntryPoint class FileDetailsFragment : ParentFragment() { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentFileDetailsBinding::bind) private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) @@ -143,7 +150,7 @@ class FileDetailsFragment : ParentFragment() { } private fun downloadFile() { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), file) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(file?.displayName.orEmpty(), file?.url.orEmpty())) markAsRead() } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt index de46927ecc..d945d5913e 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/FileListFragment.kt @@ -31,6 +31,7 @@ import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.DialogFragment import androidx.lifecycle.LiveData import androidx.work.WorkInfo +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.FileFolderManager import com.instructure.canvasapi2.models.* import com.instructure.canvasapi2.utils.ApiPrefs @@ -47,6 +48,7 @@ import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.analytics.SCREEN_VIEW_FILE_LIST import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.features.file.upload.FileUploadDialogFragment import com.instructure.pandautils.features.file.upload.FileUploadDialogParent import com.instructure.pandautils.utils.* @@ -57,17 +59,22 @@ import com.instructure.student.databinding.FragmentFileListBinding import com.instructure.student.dialog.EditTextDialog import com.instructure.student.features.files.search.FileSearchFragment import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.util.StudentPrefs +import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import java.util.* +import javax.inject.Inject @ScreenView(SCREEN_VIEW_FILE_LIST) @PageView +@AndroidEntryPoint class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentFileListBinding::bind) private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) @@ -83,7 +90,10 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent if (canvasContext.type == CanvasContext.Type.USER) { url += "users_${canvasContext.id}/" } - url += folder?.fullName?.split(" ", limit = 2)?.get(1)?.replaceFirst("files/", "") ?: "" + val fullNameParts = folder?.fullName?.split("/", limit = 2) + if ((fullNameParts?.size ?: 0) > 1) { + url += fullNameParts?.get(1) ?: "" + } } return url @@ -347,7 +357,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent // First check if the Download Manager exists, and is enabled // Then check for permissions if (PermissionUtils.hasPermissions(requireActivity(), PermissionUtils.WRITE_EXTERNAL_STORAGE)) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), item) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(item.displayName.orEmpty(), item.url.orEmpty())) } else { // Need permission requestPermissions(PermissionUtils.makeArray(PermissionUtils.WRITE_EXTERNAL_STORAGE), PermissionUtils.WRITE_FILE_PERMISSION_REQUEST_CODE) @@ -413,6 +423,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent setEmptyView(binding.emptyView, R.drawable.ic_panda_nofiles, R.string.noFiles, getNoFileSubtextId()) } StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + folder!!.id + updateFileList() } catch { toast(R.string.errorOccurred) } @@ -437,14 +448,23 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent override fun workInfoLiveDataCallback(uuid: UUID?, workInfoLiveData: LiveData) { workInfoLiveData.observe(viewLifecycleOwner) { if (it.state == WorkInfo.State.SUCCEEDED) { - recyclerAdapter?.refresh() - folder?.let { - StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + it.id + updateFileList(true) + folder?.let { fileFolder -> + StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + fileFolder.id } } } } + private fun updateFileList(includeCurrentScreen: Boolean = false) { + parentFragmentManager.fragments + .filterIsInstance(FileListFragment::class.java) + .dropLast(if (includeCurrentScreen) 0 else 1) + .forEach { fragment -> + fragment.recyclerAdapter?.refresh() + } + } + private fun createFolder() { EditTextDialog.show(requireFragmentManager(), getString(R.string.createFolder), "") { name -> tryWeave { @@ -453,6 +473,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent } recyclerAdapter?.add(newFolder) StudentPrefs.staleFolderIds = StudentPrefs.staleFolderIds + folder!!.id + updateFileList() } catch { toast(R.string.folderCreationError) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt index 97e68de475..bf7008a265 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/GradesListFragment.kt @@ -66,6 +66,8 @@ class GradesListFragment : ParentFragment(), Bookmarkable { private var gradingPeriodsList = ArrayList() private var isWhatIfGrading = false + private var restrictQuantitativeData = false + private var gradingScheme = emptyList() private lateinit var allTermsGradingPeriod: GradingPeriod private lateinit var recyclerAdapter: GradesListRecyclerAdapter @@ -154,7 +156,10 @@ class GradesListFragment : ParentFragment(), Bookmarkable { if (showWhatIfCheckBox.isChecked) { computeGrades(showTotalCheckBox.isChecked, -1) } else { - val gradeString = formatGrade(recyclerAdapter.courseGrade, !isChecked) + val gradeString = getGradeString( + recyclerAdapter.courseGrade, + !isChecked + ) txtOverallGrade.text = gradeString txtOverallGrade.contentDescription = getContentDescriptionForMinusGradeString(gradeString, requireContext()) } @@ -205,10 +210,12 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } } - override fun notifyGradeChanged(courseGrade: CourseGrade?) { + override fun notifyGradeChanged(courseGrade: CourseGrade?, restrictQuantitativeData: Boolean, gradingScheme: List) { Logger.d("Logging for Grades E2E, current total grade is: ${binding.txtOverallGrade.text}") if (!isAdded) return - val gradeString = formatGrade(courseGrade, !binding.showTotalCheckBox.isChecked) + this@GradesListFragment.restrictQuantitativeData = restrictQuantitativeData + this@GradesListFragment.gradingScheme = gradingScheme + val gradeString = getGradeString(courseGrade, !binding.showTotalCheckBox.isChecked) Logger.d("Logging for Grades E2E, new total grade is: $gradeString") binding.txtOverallGrade.text = gradeString binding.txtOverallGrade.contentDescription = getContentDescriptionForMinusGradeString(gradeString, requireContext()) @@ -294,12 +301,31 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } } - private fun formatGrade(courseGrade: CourseGrade?, isFinal: Boolean): String { + private fun getGradeString( + courseGrade: CourseGrade?, + isFinal: Boolean + ): String { if (courseGrade == null) return getString(R.string.noGradeText) return if (isFinal) { - if (courseGrade.noFinalGrade) getString(R.string.noGradeText) else NumberHelper.doubleToPercentage(courseGrade.finalScore) + if (courseGrade.hasFinalGradeString()) String.format(" (%s)", courseGrade.finalGrade) else "" + formatGrade(courseGrade.noFinalGrade, courseGrade.hasFinalGradeString(), courseGrade.finalGrade, courseGrade.finalScore) + } else { + formatGrade(courseGrade.noCurrentGrade, courseGrade.hasCurrentGradeString(), courseGrade.currentGrade, courseGrade.currentScore) + } + } + + private fun formatGrade(noGrade: Boolean, hasGradeString: Boolean, grade: String?, score: Double?): String { + return if (noGrade) { + getString(R.string.noGradeText) } else { - if (courseGrade.noCurrentGrade) getString(R.string.noGradeText) else NumberHelper.doubleToPercentage(courseGrade.currentScore) + if (courseGrade.hasCurrentGradeString()) String.format(" (%s)", courseGrade.currentGrade) else "" + if (restrictQuantitativeData) { + when { + hasGradeString -> grade.orEmpty() + gradingScheme.isNotEmpty() && score != null -> convertPercentScoreToLetterGrade(score / 100, gradingScheme) + else -> getString(R.string.noGradeText) + } + } else { + NumberHelper.doubleToPercentage(score) + if (hasGradeString) String.format(" (%s)", grade) else "" + } } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt index 6962b7d493..b4c6148bc8 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InboxComposeMessageFragment.kt @@ -319,7 +319,7 @@ class InboxComposeMessageFragment : ParentFragment(), FileUploadDialogParent { private fun handleExit() { // Check to see if the user has made any changes - if (binding.editSubject.text.isNotBlank() || binding.message.text.isNotBlank() || attachments.isNotEmpty()) { + if (binding.editSubject.text?.isNotBlank() == true || binding.message.text?.isNotBlank() == true || attachments.isNotEmpty()) { shouldAllowExit = false // Use childFragmentManager so that exiting the compose fragment also dismisses the dialog UnsavedChangesExitDialog.show(childFragmentManager) { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt index 8ec882d009..826f7f7095 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InboxConversationFragment.kt @@ -24,6 +24,7 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.InboxManager import com.instructure.canvasapi2.models.* import com.instructure.canvasapi2.utils.ApiPrefs @@ -37,6 +38,7 @@ import com.instructure.interactions.router.RouterParams import com.instructure.pandautils.analytics.SCREEN_VIEW_INBOX_CONVERSATION import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.adapter.InboxConversationAdapter @@ -46,16 +48,21 @@ import com.instructure.student.events.ConversationUpdatedEvent import com.instructure.student.events.MessageAddedEvent import com.instructure.student.interfaces.MessageAdapterCallback import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import com.instructure.student.view.AttachmentView +import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import javax.inject.Inject @ScreenView(SCREEN_VIEW_INBOX_CONVERSATION) @PageView(url = "conversations") +@AndroidEntryPoint class InboxConversationFragment : ParentFragment() { + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentInboxConversationBinding::bind) private lateinit var recyclerBinding: PandaRecyclerRefreshLayoutBinding @@ -111,7 +118,7 @@ class InboxConversationFragment : ParentFragment() { AttachmentView.AttachmentAction.DOWNLOAD -> { if (PermissionUtils.hasPermissions(requireActivity(), PermissionUtils.WRITE_EXTERNAL_STORAGE)) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), attachment = attachment) + workManager.enqueue(FileDownloadWorker.createOneTimeWorkRequest(attachment.displayName.orEmpty(), attachment.url.orEmpty())) } else { requestPermissions(PermissionUtils.makeArray(PermissionUtils.WRITE_EXTERNAL_STORAGE), PermissionUtils.WRITE_FILE_PERMISSION_REQUEST_CODE) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt index 1185f145ad..f7540bba2c 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/InternalWebviewFragment.kt @@ -29,6 +29,7 @@ import android.view.View import android.view.ViewGroup import android.webkit.WebView import android.widget.ProgressBar +import androidx.work.WorkManager import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.managers.SubmissionManager import com.instructure.canvasapi2.models.AuthenticatedSession @@ -40,12 +41,12 @@ import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.weave.* import com.instructure.interactions.router.Route import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.utils.* import com.instructure.pandautils.views.CanvasWebView import com.instructure.student.R import com.instructure.student.databinding.FragmentWebviewBinding import com.instructure.student.router.RouteMatcher -import com.instructure.student.util.FileDownloadJobIntentService import kotlinx.coroutines.Job import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -238,9 +239,7 @@ open class InternalWebviewFragment : ParentFragment() { } private fun downloadFile() { - if (downloadFilename != null && downloadUrl != null) { - FileDownloadJobIntentService.scheduleDownloadJob(requireContext(), downloadFilename!!, downloadUrl!!) - } + WorkManager.getInstance(requireContext()).enqueue(FileDownloadWorker.createOneTimeWorkRequest(downloadFilename.orEmpty(), downloadUrl.orEmpty())) } override fun onStart() { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt index ba280fcbf8..3100f10a62 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/LtiLaunchFragment.kt @@ -103,10 +103,18 @@ class LtiLaunchFragment : ParentFragment() { when { sessionLessLaunch -> { // This is specific for Studio and Gauge - url = when (canvasContext) { - is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?url=$url" - is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?url=$url" - else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?url=$url" + val id = url.substringAfterLast("/external_tools/").substringBefore("?") + url = when { + (id.toIntOrNull() != null) -> when (canvasContext) { + is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?id=$id" + is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?id=$id" + else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?id=$id" + } + else -> when (canvasContext) { + is Course -> "${ApiPrefs.fullDomain}/api/v1/courses/${canvasContext.id}/external_tools/sessionless_launch?url=$url" + is Group -> "${ApiPrefs.fullDomain}/api/v1/groups/${canvasContext.id}/external_tools/sessionless_launch?url=$url" + else -> "${ApiPrefs.fullDomain}/api/v1/accounts/self/external_tools/sessionless_launch?url=$url" + } } loadSessionlessLtiUrl(url) } diff --git a/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt index cd1d000543..7c824ed748 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/AssignmentViewHolder.kt @@ -21,6 +21,7 @@ import android.view.View import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.utils.DateHelper import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.setTextForVisibility @@ -35,7 +36,9 @@ class AssignmentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { context: Context, assignment: Assignment, courseColor: Int, - adapterToFragmentCallback: AdapterToFragmentCallback + adapterToFragmentCallback: AdapterToFragmentCallback, + restrictQuantitativeData: Boolean, + gradingSchemes: List ) = with(ViewholderCardGenericBinding.bind(itemView)) { title.text = assignment.name @@ -47,12 +50,13 @@ class AssignmentViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val submission = assignment.submission // Posted At now determines if an assignment is muted, even for old gradebook - if (submission?.postedAt == null) { + val hideGrade = restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true && gradingSchemes.isEmpty() + if (submission?.postedAt == null || hideGrade) { // Mute that score points.visibility = View.GONE } else { points.visibility = View.VISIBLE - BinderUtils.setupGradeText(context, points, assignment, submission, courseColor) + BinderUtils.setupGradeText(context, points, assignment, submission, courseColor, restrictQuantitativeData, gradingSchemes) } val drawable = BinderUtils.getAssignmentIcon(assignment) diff --git a/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt index f8c4edce2e..5f383adb06 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/CourseViewHolder.kt @@ -89,20 +89,35 @@ class CourseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { } else { gradeTextView.setVisible() lockedGradeImage.setGone() - setGradeView(gradeTextView, courseGrade, course.textAndIconColor, root.context) + setGradeView(gradeTextView, courseGrade, course.textAndIconColor, root.context, course.settings?.restrictQuantitativeData ?: false) } } else { gradeLayout.setGone() } } - private fun setGradeView(textView: TextView, courseGrade: CourseGrade, color: Int, context: Context) { + private fun setGradeView( + textView: TextView, + courseGrade: CourseGrade, + color: Int, + context: Context, + restrictQuantitativeData: Boolean + ) { if(courseGrade.noCurrentGrade) { textView.text = context.getString(R.string.noGradeText) } else { - val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) - textView.text = "${if(courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" - textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade ?: "", context) + if (restrictQuantitativeData) { + if (courseGrade.currentGrade.isNullOrEmpty()) { + textView.text = context.getString(R.string.noGradeText) + } else { + textView.text = "${courseGrade.currentGrade.orEmpty()}" + textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade.orEmpty(), context) + } + } else { + val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) + textView.text = "${if(courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" + textView.contentDescription = getContentDescriptionForMinusGradeString(courseGrade.currentGrade ?: "", context) + } } textView.setTextColor(color) } diff --git a/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt index e8c9ed4b75..19179863a4 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/GradeViewHolder.kt @@ -23,8 +23,15 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.utils.DateHelper -import com.instructure.pandautils.utils.* +import com.instructure.pandautils.utils.ColorKeeper +import com.instructure.pandautils.utils.Const +import com.instructure.pandautils.utils.ThemePrefs +import com.instructure.pandautils.utils.backgroundColor +import com.instructure.pandautils.utils.setGone +import com.instructure.pandautils.utils.setVisible +import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.adapter.GradesListRecyclerAdapter import com.instructure.student.databinding.ViewholderGradeBinding @@ -61,12 +68,17 @@ class GradeViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { points.setGone() } else { val submission = assignment.submission + val course = canvasContext as? Course + val restrictQuantitativeData = course?.settings?.restrictQuantitativeData ?: false + val gradingScheme = course?.gradingScheme ?: emptyList() if (submission != null && Const.PENDING_REVIEW == submission.workflowState) { points.setGone() icon.setNestedIcon(R.drawable.ic_complete_solid, canvasContext.backgroundColor) + } else if (restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true && gradingScheme.isEmpty()) { + points.setGone() } else { points.setVisible() - val (grade, contentDescription) = BinderUtils.getGrade(assignment, submission, context) + val (grade, contentDescription) = BinderUtils.getGrade(assignment, submission, context, restrictQuantitativeData, gradingScheme) points.text = grade points.contentDescription = contentDescription } diff --git a/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt index 69c8688478..28c299efd5 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/ModuleViewHolder.kt @@ -44,7 +44,8 @@ class ModuleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { adapterToFragmentCallback: ModuleAdapterToFragmentCallback?, courseColor: Int, isFirstItem: Boolean, - isLastItem: Boolean + isLastItem: Boolean, + restrictQuantitativeData: Boolean ) = with(ViewholderModuleBinding.bind(itemView)) { val isLocked = ModuleUtility.isGroupLocked(moduleObject) @@ -146,7 +147,7 @@ class ModuleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { hasDate = false } val pointsPossible = details.pointsPossible - if (pointsPossible.isValid()) { + if (pointsPossible.isValid() && !restrictQuantitativeData) { points.text = context.getString( R.string.totalPoints, NumberHelper.formatDecimal(pointsPossible.toDouble(), 2, true) diff --git a/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt index c0b3b0ae9f..113718dd12 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/NotificationViewHolder.kt @@ -24,7 +24,9 @@ import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.StreamItem +import com.instructure.canvasapi2.utils.convertScoreToLetterGrade import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.adapter.NotificationListRecyclerAdapter @@ -98,17 +100,28 @@ class NotificationViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) drawableResId = R.drawable.ic_assignment icon.contentDescription = context.getString(R.string.assignmentIcon) + val course = item.canvasContext as? Course + val restrictQuantitativeData = course?.settings?.restrictQuantitativeData.orDefault() + val gradingScheme = course?.gradingScheme.orEmpty() // Need to prepend "Grade" in the message if there is a valid score if (item.score != -1.0) { // If the submission has a grade (like a letter or percentage) display it - if (item.grade != null - && item.grade != "" - && item.grade != "null" - ) { - description.text = context.resources.getString(R.string.grade) + ": " + item.grade + val pointsPossible = item.assignment?.pointsPossible + val grade = if (item.assignment?.isGradingTypeQuantitative == true && restrictQuantitativeData && pointsPossible != null) { + convertScoreToLetterGrade(item.score, pointsPossible, gradingScheme) + } else { + item.grade + } + if (grade != null && grade != "" && grade != "null") { + description.text = context.resources.getString(R.string.grade) + ": " + grade } else { description.text = context.resources.getString(R.string.grade) + description.text } + } else if (item.excused) { + description.text = context.resources.getString(R.string.gradeExcused) + description.setVisible() + } else { + description.text = context.resources.getString(R.string.gradeUpdated) } } StreamItem.Type.CONVERSATION -> { diff --git a/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt b/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt index 579959a89a..e38dcad05e 100644 --- a/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt +++ b/apps/student/src/main/java/com/instructure/student/holders/QuizViewHolder.kt @@ -35,7 +35,13 @@ import java.util.* class QuizViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - fun bind(item: Quiz, adapterToFragmentCallback: AdapterToFragmentCallback?, context: Context, iconAndTextColor: Int) = with(ViewholderQuizBinding.bind(itemView)) { + fun bind( + item: Quiz, + adapterToFragmentCallback: AdapterToFragmentCallback?, + context: Context, + iconAndTextColor: Int, + restrictQuantitativeData: Boolean + ) = with(ViewholderQuizBinding.bind(itemView)) { root.setOnClickListener { adapterToFragmentCallback?.onRowClicked(item, adapterPosition, true) } // Title @@ -61,7 +67,7 @@ class QuizViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { // Points and Questions val possiblePoints = item.pointsPossible?.toDoubleOrNull() ?: 0.0 - points.setVisible(possiblePoints > 0).text = context.resources.getQuantityString( + points.setVisible(possiblePoints > 0 && !restrictQuantitativeData).text = context.resources.getQuantityString( R.plurals.pointCount, possiblePoints.toInt(), NumberHelper.formatDecimal(possiblePoints, 2, true) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt index 1c7bb33fc3..bd88b91d10 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsEffectHandler.kt @@ -122,7 +122,22 @@ class SubmissionDetailsEffectHandler : EffectHandler?, val studioLTIToolResult: DataResult?, val isObserver: Boolean = false, - val assignmentEnhancementsEnabled: Boolean + val assignmentEnhancementsEnabled: Boolean, + val restrictQuantitativeData: Boolean = false ) : SubmissionDetailsEvent() data class SubmissionCommentsUpdated(val submissionComments: List) : SubmissionDetailsEvent() } @@ -74,7 +75,8 @@ data class SubmissionDetailsModel( val ltiTool: DataResult? = null, val initialSelectedSubmissionAttempt: Long? = null, val submissionComments: List? = null, - val assignmentEnhancementsEnabled: Boolean = false + val assignmentEnhancementsEnabled: Boolean = false, + val restrictQuantitativeData: Boolean = false ) sealed class SubmissionDetailsContentType { diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt index 3bd581fc50..cade1dc24a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/SubmissionDetailsPresenter.kt @@ -80,7 +80,8 @@ object SubmissionDetailsPresenter : Presenter = emptyMap() ) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt index 0cad9f9bb6..a16d2f8595 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/SubmissionRubricPresenter.kt @@ -24,12 +24,10 @@ import com.instructure.canvasapi2.models.RubricCriterionRating import com.instructure.canvasapi2.utils.NumberHelper import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.validOrNull -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R import com.instructure.student.mobius.assignmentDetails.ui.gradeCell.GradeCellViewState import com.instructure.student.mobius.common.ui.Presenter -import java.util.HashMap object SubmissionRubricPresenter : Presenter { @@ -46,7 +44,7 @@ object SubmissionRubricPresenter : Presenter RatingData( diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt index 053e9241ce..a8268a84c0 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/rubric/ui/SubmissionRubricFragment.kt @@ -22,6 +22,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission import com.instructure.pandautils.analytics.SCREEN_VIEW_SUBMISSION_RUBRIC import com.instructure.pandautils.analytics.ScreenView +import com.instructure.pandautils.utils.BooleanArg import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.ParcelableArg import com.instructure.student.databinding.FragmentSubmissionRubricBinding @@ -29,11 +30,14 @@ import com.instructure.student.mobius.assignmentDetails.submissionDetails.drawer import com.instructure.student.mobius.assignmentDetails.submissionDetails.ui.SubmissionDetailsTabData import com.instructure.student.mobius.common.ui.MobiusFragment +private const val RESTRICT_QUANTITATIVE_DATA = "restrictQuantitativeData" + @ScreenView(SCREEN_VIEW_SUBMISSION_RUBRIC) class SubmissionRubricFragment : MobiusFragment() { private var submission by ParcelableArg(key = Const.SUBMISSION) private var assignment by ParcelableArg(key = Const.ASSIGNMENT) + private var restrictQuantitativeData by BooleanArg(key = RESTRICT_QUANTITATIVE_DATA) override fun makeEffectHandler() = SubmissionRubricEffectHandler() @@ -43,13 +47,13 @@ class SubmissionRubricFragment : override fun makePresenter() = SubmissionRubricPresenter - override fun makeInitModel() = SubmissionRubricModel(assignment, submission) + override fun makeInitModel() = SubmissionRubricModel(assignment, submission, restrictQuantitativeData) companion object { fun newInstance(data: SubmissionDetailsTabData.RubricData) = SubmissionRubricFragment().apply { submission = data.submission assignment = data.assignment + restrictQuantitativeData = data.restrictQuantitativeData } } - } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt index 2fb7cfcd08..a956c7e85f 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/ui/SubmissionDetailsViewState.kt @@ -50,7 +50,8 @@ sealed class SubmissionDetailsTabData(val tabName: String) { data class RubricData( val name: String, val assignment: Assignment, - val submission: Submission + val submission: Submission, + val restrictQuantitativeData: Boolean = false ) : SubmissionDetailsTabData(name) } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt index 0f0bc8b7e9..42cf48e75c 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt @@ -24,7 +24,6 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.NumberHelper -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.getContentDescriptionForMinusGradeString import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.R @@ -69,10 +68,12 @@ sealed class GradeCellViewState { fun fromSubmission( context: Context, assignment: Assignment, - submission: Submission? + submission: Submission?, + restrictQuantitativeData: Boolean = false ): GradeCellViewState { - // Return empty state if unsubmitted and ungraded, or "Not Graded" grading type - if ((submission?.submittedAt == null && submission?.isGraded != true) || assignment.gradingType == Assignment.NOT_GRADED_TYPE) { + // Return empty state if unsubmitted and ungraded, or "Not Graded" grading type or quantitative data is restricted + val hideGrades = restrictQuantitativeData && assignment.isGradingTypeQuantitative && submission?.excused != true + if ((submission?.submittedAt == null && submission?.isGraded != true) || assignment.gradingType == Assignment.NOT_GRADED_TYPE || hideGrades) { return Empty } @@ -87,8 +88,8 @@ sealed class GradeCellViewState { /* The 'Out of' text abbreviates the word "points" to "pts" which is read as "P T S" by screen readers, so * we use a second string with the full word "points" as a content description. */ val pointsPossibleText = NumberHelper.formatDecimal(assignment.pointsPossible, 2, true) - val outOfText = context.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) - val outOfContentDescriptionText = context.getString(R.string.outOfPointsFormatted, pointsPossibleText) + val outOfText = if (restrictQuantitativeData) "" else context.getString(R.string.outOfPointsAbbreviatedFormatted, pointsPossibleText) + val outOfContentDescriptionText = if (restrictQuantitativeData) "" else context.getString(R.string.outOfPointsFormatted, pointsPossibleText) // Excused if (submission.excused) { @@ -116,6 +117,21 @@ sealed class GradeCellViewState { ) } + if (restrictQuantitativeData) { + val grade = submission.grade.orEmpty() + val accessibleGradeString = getContentDescriptionForMinusGradeString(grade, context) + val gradeCellContentDescription = context.getString(R.string.a11y_gradeCellContentDescriptionLetterGradeOnly, accessibleGradeString) + + return GradeData( + showCompleteIcon = true, + graphPercent = 1.0f, + accentColor = accentColor, + grade = grade, + gradeContentDescription = accessibleGradeString, + gradeCellContentDescription = gradeCellContentDescription + ) + } + val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) val graphPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() diff --git a/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt b/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt index ff84ef1cc8..0d264ec66a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/syllabus/ui/SyllabusView.kt @@ -53,11 +53,7 @@ class SyllabusView(val canvasContext: CanvasContext, inflater: LayoutInflater, p override fun onTabUnselected(tab: TabLayout.Tab?) {} override fun onTabSelected(tab: TabLayout.Tab?) { - if (tab?.position == 0) { - binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusScrollView) - } else { - binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusEventsRecycler, R.id.syllabusEmptyView) - } + setupSwipeableChildren(tab?.position) } } @@ -96,18 +92,34 @@ class SyllabusView(val canvasContext: CanvasContext, inflater: LayoutInflater, p is SyllabusViewState.Loaded -> { binding.swipeRefreshLayout.isRefreshing = false + val pager = binding.syllabusPager + val hasBoth = state.eventsState != null && state.syllabus != null binding.syllabusTabLayout.setVisible(hasBoth) - binding.syllabusPager.canSwipe = hasBoth + pager.canSwipe = hasBoth - binding.syllabusPager.setCurrentItem(if (state.syllabus == null) 1 else 0, false) + pager.setCurrentItem(if (state.syllabus == null) 1 else 0, false) + + if (state.syllabus != null) webviewBinding?.syllabusWebViewWrapper?.loadHtml( + state.syllabus, + context.getString(com.instructure.pandares.R.string.syllabus) + ) - if (state.syllabus != null) webviewBinding?.syllabusWebViewWrapper?.loadHtml(state.syllabus, context.getString(com.instructure.pandares.R.string.syllabus)) if (state.eventsState != null) renderEvents(state.eventsState) + + setupSwipeableChildren(pager.currentItem) } } } + private fun setupSwipeableChildren(position: Int?) { + if (position == 0) { + binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusScrollView) + } else { + binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusEventsRecycler, R.id.syllabusEmptyView) + } + } + private fun renderEvents(eventsState: EventsViewState) { with (eventsState) { eventsBinding?.syllabusEmptyView?.setVisible(visibility.empty) @@ -115,12 +127,6 @@ class SyllabusView(val canvasContext: CanvasContext, inflater: LayoutInflater, p eventsBinding?.syllabusEventsRecycler?.setVisible(visibility.list) } - if (binding.syllabusPager.currentItem == 0) { - binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusScrollView) - } else { - binding.swipeRefreshLayout.setSwipeableChildren(R.id.syllabusEventsRecycler, R.id.syllabusEmptyView) - } - when (eventsState) { EventsViewState.Error -> { eventsBinding?.syllabusRetry?.onClick { consumer?.accept(SyllabusEvent.PullToRefresh) } diff --git a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt index 228592e6fc..20af9eb4ac 100644 --- a/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt +++ b/apps/student/src/main/java/com/instructure/student/util/BinderUtils.kt @@ -23,8 +23,10 @@ import android.view.View import android.widget.TextView import androidx.core.content.ContextCompat import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.NumberHelper +import com.instructure.canvasapi2.utils.convertScoreToLetterGrade import com.instructure.canvasapi2.utils.isValid import com.instructure.canvasapi2.utils.validOrNull import com.instructure.pandautils.utils.* @@ -36,13 +38,13 @@ object BinderUtils { @Suppress("DEPRECATION") fun getHtmlAsText(html: String?) = html?.validOrNull()?.let { StringUtilities.simplifyHTML(Html.fromHtml(it)) } - fun getGrade(assignment: Assignment, submission: Submission?, context: Context): DisplayGrade { + fun getGrade(assignment: Assignment, submission: Submission?, context: Context, restrictQuantitativeData: Boolean, gradingScheme: List): DisplayGrade { val possiblePoints = assignment.pointsPossible val pointsPossibleText = NumberHelper.formatDecimal(possiblePoints, 2, true) // No submission if (submission == null) { - return if (possiblePoints > 0) { + return if (possiblePoints > 0 && !restrictQuantitativeData) { DisplayGrade( context.getString( R.string.gradeFormatScoreOutOfPointsPossible, @@ -58,18 +60,22 @@ object BinderUtils { // Excused if (submission.excused) { - return DisplayGrade( - context.getString( - R.string.gradeFormatScoreOutOfPointsPossible, - context.getString(R.string.excused), - pointsPossibleText - ), - context.getString( - R.string.contentDescriptionScoreOutOfPointsPossible, - context.getString(R.string.gradeExcused), - pointsPossibleText + if (restrictQuantitativeData) { + return DisplayGrade(context.getString(R.string.gradeExcused)) + } else { + return DisplayGrade( + context.getString( + R.string.gradeFormatScoreOutOfPointsPossible, + context.getString(R.string.excused), + pointsPossibleText + ), + context.getString( + R.string.contentDescriptionScoreOutOfPointsPossible, + context.getString(R.string.gradeExcused), + pointsPossibleText + ) ) - ) + } } val grade = submission.grade ?: return DisplayGrade() @@ -82,26 +88,36 @@ object BinderUtils { * more closely match web, e.g. "15 / 20 (2.0)" or "80 / 100 (B-)". */ if (gradingType == Assignment.GradingType.LETTER_GRADE || gradingType == Assignment.GradingType.GPA_SCALE) { - val scoreText = NumberHelper.formatDecimal(submission.score, 2, true) - val possiblePointsText = NumberHelper.formatDecimal(possiblePoints, 2, true) - return DisplayGrade( - context.getString( - R.string.formattedScoreWithPointsPossibleAndGrade, - scoreText, - possiblePointsText, - grade - ), - context.getString( - R.string.contentDescriptionScoreWithPointsPossibleAndGrade, - scoreText, - possiblePointsText, - gradeContentDescription + if (restrictQuantitativeData) { + return DisplayGrade(grade, gradeContentDescription) + } else { + val scoreText = NumberHelper.formatDecimal(submission.score, 2, true) + val possiblePointsText = NumberHelper.formatDecimal(possiblePoints, 2, true) + return DisplayGrade( + context.getString( + R.string.formattedScoreWithPointsPossibleAndGrade, + scoreText, + possiblePointsText, + grade + ), + context.getString( + R.string.contentDescriptionScoreWithPointsPossibleAndGrade, + scoreText, + possiblePointsText, + gradeContentDescription + ) ) - ) + } + } + + if (restrictQuantitativeData && assignment.isGradingTypeQuantitative) { + val letterGrade = convertScoreToLetterGrade(submission.score, assignment.pointsPossible, gradingScheme) + return DisplayGrade(letterGrade, getContentDescriptionForMinusGradeString(letterGrade, context).validOrNull() ?: letterGrade) } // Numeric grade submission.grade?.toDoubleOrNull()?.let { parsedGrade -> + if (restrictQuantitativeData) return DisplayGrade() val formattedGrade = NumberHelper.formatDecimal(parsedGrade, 2, true) return DisplayGrade( context.getString( @@ -121,7 +137,8 @@ object BinderUtils { return when (grade) { "complete" -> return DisplayGrade(context.getString(R.string.gradeComplete)) "incomplete" -> return DisplayGrade(context.getString(R.string.gradeIncomplete)) - else -> DisplayGrade(grade, gradeContentDescription) + // Other remaining case is where the grade is displayed as a percentage + else -> if (restrictQuantitativeData) DisplayGrade() else DisplayGrade(grade, gradeContentDescription) } } @@ -130,11 +147,12 @@ object BinderUtils { textView: TextView, assignment: Assignment, submission: Submission, - color: Int + color: Int, + restrictQuantitativeData: Boolean, + gradingScheme: List ) { - val hasGrade = submission.grade.isValid() - val (grade, contentDescription) = getGrade(assignment, submission, context) - if (hasGrade) { + val (grade, contentDescription) = getGrade(assignment, submission, context, restrictQuantitativeData, gradingScheme) + if (!submission.excused && grade.isValid()) { textView.text = grade textView.contentDescription = contentDescription textView.setTextAppearance(R.style.TextStyle_Grade) diff --git a/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt b/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt deleted file mode 100644 index 6570caec0d..0000000000 --- a/apps/student/src/main/java/com/instructure/student/util/FileDownloadJobIntentService.kt +++ /dev/null @@ -1,248 +0,0 @@ -/* - * Copyright (C) 2016 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ - -package com.instructure.student.util - -import android.app.DownloadManager -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Environment -import android.util.Log -import androidx.core.app.JobIntentService -import androidx.core.app.NotificationCompat -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.instructure.canvasapi2.models.Attachment -import com.instructure.canvasapi2.models.FileFolder -import com.instructure.student.R -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.buffer -import okio.sink -import java.io.File - - -class FileDownloadJobIntentService : JobIntentService() { - - override fun onHandleWork(intent: Intent) { - val fileName = intent.extras?.getString(FILE_NAME) ?: "" - val fileUrl = intent.extras?.getString(FILE_URL) ?: "" - val fileSize = intent.extras?.getLong(FILE_SIZE) ?: 0L - val notificationId = intent.extras?.getInt(NOTIFICATION_ID) ?: 0 - - val downloadedFileName = createDownloadFileName(fileName) - - registerNotificationChannel(this) - - // Tell Android where to send the user if they click on the notification - val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) - val pendingIntent = PendingIntent.getActivity(this, 0, viewDownloadIntent, PendingIntent.FLAG_IMMUTABLE) - - // Setup a notification - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.downloadingFile)) - .setSmallIcon(android.R.drawable.stat_sys_download) - .setContentText(downloadedFileName) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setProgress(100, 0, true) - .setOngoing(true) - .setOnlyAlertOnce(true) - - // Show the notification - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.notify(notificationId, notification.build()) - - val resultStatus = downloadFile(downloadedFileName, fileUrl) { downloaded -> - // Only update our notification if we know the file size - // If the file size is 0, we can't keep track of anything - val percentage = when { - fileSize == 0L || downloaded <= 0 -> 0F - else -> ((downloaded.toFloat() / fileSize) * 100).coerceIn(0f..100f).toFloat() - } - - notification.setProgress(100, percentage.toInt(), fileSize <= 0) - notificationManager.notify(notificationId, notification.build()) - } - - when (resultStatus) { - is DownloadFailed -> { - // We'll want to know if download streams are failing to open - FirebaseCrashlytics.getInstance().recordException(Throwable("The file stream failed to open when downloading a file")) - notification.setContentText(getString(R.string.downloadFailed)) - } - is BadFileUrl, is BadFileName -> notification.setContentText(getString(R.string.downloadFailed)) - is DownloadSuccess -> { - notification - .setContentTitle(downloadedFileName) - .setContentText(getString(R.string.downloadSuccessful)) - } - } - - notification - .setProgress(0, 0, false) - .setSmallIcon(android.R.drawable.stat_sys_download_done) - .setOngoing(false) - - notificationManager.notify(notificationId, notification.build()) - } - - private fun createDownloadFileName(fileName: String): String { - var downloadedFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - fileName - ) - val fileNameWithoutExtension = downloadedFile.nameWithoutExtension - val fileExtension = downloadedFile.extension - var counter = 1 - while (downloadedFile.exists()) { - downloadedFile = File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), - "$fileNameWithoutExtension($counter).$fileExtension" - ) - counter++ - } - - return downloadedFile.name - } - - private fun downloadFile(fileName: String, fileUrl: String, updateCallback: (Long) -> Unit): DownloadStatus { - val debounce = 1000 // The time to delay sending up a notification update; Sending them too fast can cause the system to skip some updates and can cause janky UI - // NOTE: The WRITE_EXTERNAL_STORAGE permission should have been checked by this point; This will fail if that permission is not granted - Log.d(TAG, "downloadFile URL: $fileUrl") - val downloadedFile = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName) - - // Make sure we have a valid file url and name - if (fileUrl.isBlank()) { - return BadFileUrl() - } else if (fileName.isBlank()) { - // Set notification message error - return BadFileName() - } - - // Download the file - try { - val okHttp = OkHttpClient.Builder().build() - val request = Request.Builder().url(fileUrl).build() - val source = okHttp.newCall(request).execute().body?.source() ?: return DownloadFailed() - val sink = downloadedFile.sink().buffer() - - var startTime = System.currentTimeMillis() - var downloaded = 0L - var read: Long - updateCallback(0) - - val bufferSize = 8L * 1024 - val sinkBuffer = sink.buffer - - // Perform download. - read = source.read(sinkBuffer, bufferSize) - while (read != -1L) { - downloaded += read - sink.emit() - // Debounce the notification - if (System.currentTimeMillis() - startTime > debounce) { - // Update the notification - updateCallback(downloaded) - startTime = System.currentTimeMillis() - } - read = source.read(sinkBuffer, bufferSize) - } - - // Cleanup - sink.flush() - sink.close() - source.close() - return DownloadSuccess() - - } catch (e: Exception) { - downloadedFile.delete() - return DownloadFailed() - } - } - - companion object { - val TAG = "DownloadMedia" - // Keys for Job Intent Extras - val FILE_NAME = "filename" - val FILE_URL = "url" - val FILE_SIZE = "filesize" - val CONTENT_TYPE = "contenttype" - val NOTIFICATION_ID = "notificationid" - val USE_HTTPURLCONNECTION = "usehttpurlconnection" - - const val CHANNEL_ID = "uploadChannel" - - // Notification ID is passed into the extras of the job, make sure to use that for any notification updates inside the job - var notificationId = 1 - get() = ++field - - // Job ID must be unique to this Job class - val JOB_ID = 1987 - - private fun createJobIntent(fileName: String, fileUrl: String, fileSize: Long): Intent = Intent().apply { - putExtras(Bundle().apply { - putString(FILE_NAME, fileName) - putString(FILE_URL, fileUrl) - putLong(FILE_SIZE, fileSize) - putInt(NOTIFICATION_ID, notificationId) - }) - } - - @JvmOverloads - fun scheduleDownloadJob(context: Context, item: FileFolder? = null, attachment: Attachment? = null) { - val fileName = item?.displayName ?: attachment?.filename ?: "" - val url = item?.url ?: attachment?.url ?: "" - val fileSize = item?.size ?: attachment?.size ?: 0L - - scheduleDownloadJob(context, fileName, url, fileSize) - } - - fun scheduleDownloadJob(context: Context, fileName: String, fileUrl: String, fileSize: Long = 0) { - val intent = FileDownloadJobIntentService.createJobIntent(fileName, fileUrl, fileSize) - enqueueWork(context, FileDownloadJobIntentService::class.java, JOB_ID, intent) - } - - fun registerNotificationChannel(context: Context) { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Prevents recreation of notification channel if it exists. - if (notificationManager.notificationChannels.any { it.id == CHANNEL_ID }) return - - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is new and not in the support library - val name = context.getString(R.string.notificationChannelNameFileUploadsName) - val description = context.getString(R.string.notificationChannelNameFileUploadsDescription) - val importance = NotificationManager.IMPORTANCE_HIGH - val channel = NotificationChannel(CHANNEL_ID, name, importance) - channel.description = description - - // Register the channel with the system - notificationManager.createNotificationChannel(channel) - } - } -} - -sealed class DownloadStatus -class BadFileUrl : DownloadStatus() -class BadFileName : DownloadStatus() -class DownloadSuccess : DownloadStatus() -class DownloadFailed : DownloadStatus() diff --git a/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt b/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt index bf3f8e30b8..a743dd7cc4 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/GradesViewWidgetService.kt @@ -29,8 +29,8 @@ import com.instructure.student.R import com.instructure.student.activity.InterwebsToApplication import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.CourseGrade import com.instructure.canvasapi2.utils.* -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.Const import java.io.Serializable @@ -98,7 +98,8 @@ class GradesViewWidgetService : BaseRemoteViewsService(), Serializable { if (courseGrade.noCurrentGrade) { row.setTextViewText(R.id.courseGrade, applicationContext.getString(R.string.noGradeText)) } else { - row.setTextViewText(R.id.courseGrade, NumberHelper.doubleToPercentage(courseGrade.currentScore, 2)) + val grade = formatGrade(streamItem, courseGrade) + row.setTextViewText(R.id.courseGrade, grade) } } } @@ -106,7 +107,20 @@ class GradesViewWidgetService : BaseRemoteViewsService(), Serializable { row.setInt(R.id.courseIndicator, "setColorFilter", getCanvasContextTextColor(appWidgetId, streamItem)) } - + + private fun formatGrade(course: Course, courseGrade: CourseGrade): String { + return if (course.settings?.restrictQuantitativeData == true) { + if (courseGrade.currentGrade.isNullOrEmpty()) { + applicationContext.getString(R.string.noGradeText) + } else { + courseGrade.currentGrade.orEmpty() + } + } else { + val scoreString = NumberHelper.doubleToPercentage(courseGrade.currentScore, 2) + "${if (courseGrade.hasCurrentGradeString()) courseGrade.currentGrade else ""} $scoreString" + } + } + override fun clearViewData(row: RemoteViews) { row.setTextViewText(R.id.courseGrade, "") row.setTextViewText(R.id.courseTerm, "") @@ -119,7 +133,7 @@ class GradesViewWidgetService : BaseRemoteViewsService(), Serializable { if(NetworkUtils.isNetworkAvailable && ApiPrefs.user != null) { try { // Force network so we always get the latest data for grades - setData(CourseManager.getCoursesSynchronous(true).filter + setData(CourseManager.getCoursesSynchronousWithGradingScheme(true).filter { it.isFavorite && it.isCurrentEnrolment() && !it.isInvited() }) } catch (e: Throwable) { Logger.e("Could not load " + this::class.java.simpleName + " widget. " + e.message) diff --git a/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt b/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt index b0731f4730..a012f9f8fc 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/NotificationViewWidgetService.kt @@ -31,9 +31,9 @@ import com.instructure.canvasapi2.managers.GroupManager import com.instructure.canvasapi2.managers.InboxManager import com.instructure.canvasapi2.managers.StreamManager import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.StreamItem import com.instructure.canvasapi2.utils.* -import com.instructure.pandautils.utils.ColorKeeper import com.instructure.student.R import com.instructure.student.activity.NotificationWidgetRouter import com.instructure.student.util.StringUtilities @@ -86,8 +86,11 @@ class NotificationViewWidgetService : BaseRemoteViewsService(), Serializable { } if (!BaseRemoteViewsService.shouldHideDetails(appWidgetId)) { - if (streamItem.getMessage(ContextKeeper.appContext) != null) { - row.setTextViewText(R.id.message, StringUtilities.simplifyHTML(Html.fromHtml(streamItem.getMessage(ContextKeeper.appContext), Html.FROM_HTML_MODE_LEGACY))) + val restrictQuantitativeData = (streamItem.canvasContext as? Course)?.settings?.restrictQuantitativeData ?: false + val gradingScheme = (streamItem.canvasContext as? Course)?.gradingScheme ?: emptyList() + val message = streamItem.getMessage(ContextKeeper.appContext, restrictQuantitativeData, gradingScheme) + if (message != null) { + row.setTextViewText(R.id.message, StringUtilities.simplifyHTML(Html.fromHtml(message, Html.FROM_HTML_MODE_LEGACY))) row.setTextColor(R.id.message, BaseRemoteViewsService.getWidgetSecondaryTextColor(appWidgetId, applicationContext)) } else { row.setTextViewText(R.id.message, "") @@ -138,7 +141,7 @@ class NotificationViewWidgetService : BaseRemoteViewsService(), Serializable { override fun loadData() { if(NetworkUtils.isNetworkAvailable && ApiPrefs.user != null) { try { - val courses = CourseManager.getCoursesSynchronous(true) + val courses = CourseManager.getCoursesSynchronousWithGradingScheme(true) .filter { it.isFavorite && !it.accessRestrictedByDate && !it.isInvited() } val groups = GroupManager.getFavoriteGroupsSynchronous(false) val userStream = StreamManager.getUserStreamSynchronous(25, true).toMutableList() diff --git a/apps/student/src/main/java/com/instructure/student/widget/TodoViewWidgetService.kt b/apps/student/src/main/java/com/instructure/student/widget/TodoViewWidgetService.kt index 0a74062e27..14def809cb 100644 --- a/apps/student/src/main/java/com/instructure/student/widget/TodoViewWidgetService.kt +++ b/apps/student/src/main/java/com/instructure/student/widget/TodoViewWidgetService.kt @@ -35,7 +35,6 @@ import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.ScheduleItem import com.instructure.canvasapi2.models.ToDo import com.instructure.canvasapi2.utils.* -import com.instructure.pandautils.utils.ColorKeeper import java.io.Serializable import java.util.* @@ -161,7 +160,7 @@ class TodoViewWidgetService : BaseRemoteViewsService(), Serializable { override fun loadData() { if(NetworkUtils.isNetworkAvailable && ApiPrefs.user != null) { try { - val courses = CourseManager.getCoursesSynchronous(true) + val courses = CourseManager.getCoursesSynchronousWithGradingScheme(true) .filter { it.isFavorite && !it.accessRestrictedByDate && !it.isInvited() } val groups = GroupManager.getFavoriteGroupsSynchronous(true) val todos = ToDoManager.getTodosSynchronous(ApiPrefs.user!!, true) diff --git a/apps/student/src/main/res/layout/fragment_assignment_details.xml b/apps/student/src/main/res/layout/fragment_assignment_details.xml index 867fb72753..96b9261fdf 100644 --- a/apps/student/src/main/res/layout/fragment_assignment_details.xml +++ b/apps/student/src/main/res/layout/fragment_assignment_details.xml @@ -74,6 +74,7 @@ android:text="@{viewModel.data.points}" android:textColor="@color/textDark" android:textSize="16sp" + app:visible="@{!viewModel.data.points.isEmpty()}" app:layout_constraintBottom_toBottomOf="@id/submissionStatusIcon" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/submissionStatusIcon" @@ -90,6 +91,7 @@ app:imageRes="@{viewModel.data.submissionStatusIcon}" app:layout_constraintStart_toEndOf="@id/points" app:layout_constraintTop_toBottomOf="@id/assignmentName" + app:layout_goneMarginStart="16dp" app:tint="@{viewModel.data.submissionStatusTint}" tools:src="@drawable/ic_complete_solid" tools:tint="@color/textSuccess" /> diff --git a/apps/student/src/main/res/layout/fragment_file_search.xml b/apps/student/src/main/res/layout/fragment_file_search.xml index ad83e1a176..d4c3143733 100644 --- a/apps/student/src/main/res/layout/fragment_file_search.xml +++ b/apps/student/src/main/res/layout/fragment_file_search.xml @@ -16,6 +16,7 @@ --> diff --git a/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml b/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml index 16b4e10ed5..6a623ffdbb 100644 --- a/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml +++ b/apps/student/src/main/res/layout/fragment_inbox_compose_message.xml @@ -120,22 +120,26 @@ - - + android:layout_height="wrap_content"> + + + - + android:layout_height="wrap_content"> + + + diff --git a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml index 202c4d34cd..eb03f71252 100644 --- a/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml +++ b/apps/student/src/main/res/layout/fragment_unsupported_file_type.xml @@ -48,7 +48,7 @@ android:layout_height="64dp" android:importantForAccessibility="no" android:scaleType="fitCenter" - android:tint="@color/textLight" + app:tint="@color/textDark" app:srcCompat="@drawable/ic_canvas_logo_red"/> diff --git a/apps/student/src/main/res/values/styles.xml b/apps/student/src/main/res/values/styles.xml index ab7bd8654a..f7f6d76219 100644 --- a/apps/student/src/main/res/values/styles.xml +++ b/apps/student/src/main/res/values/styles.xml @@ -255,4 +255,9 @@ @color/textDark + + diff --git a/apps/student/src/main/res/values/themes_canvastheme.xml b/apps/student/src/main/res/values/themes_canvastheme.xml index 25ff54c211..286ae42ffc 100755 --- a/apps/student/src/main/res/values/themes_canvastheme.xml +++ b/apps/student/src/main/res/values/themes_canvastheme.xml @@ -43,6 +43,7 @@ @style/ModalDialogStyle @style/AnnotationNoteHinter @color/backgroundLight + @style/DatePickerStyle + + diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CoursesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CoursesApi.kt index 2832736e43..dbbfd36533 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CoursesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/CoursesApi.kt @@ -82,7 +82,8 @@ object CoursesApi { fun createCourse( enrollmentTermId: Long? = null, publish: Boolean = true, - coursesService: CoursesService = adminCoursesService + coursesService: CoursesService = adminCoursesService, + syllabusBody: String? = null ): CourseApiModel { val randomCourseName = Randomizer.randomCourseName() val course = CreateCourseWrapper( @@ -90,7 +91,8 @@ object CoursesApi { course = CreateCourse( name = randomCourseName, courseCode = randomCourseName.substring(0, 2), - enrollmentTermId = enrollmentTermId + enrollmentTermId = enrollmentTermId, + syllabusBody = syllabusBody ) ) return coursesService diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt index e9f11adc70..32e480f9e4 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/PagesApi.kt @@ -41,10 +41,11 @@ object PagesApi { courseId: Long, published: Boolean, frontPage: Boolean, + editingRoles: String? = null, token: String, body: String = Randomizer.randomPageBody() ): PageApiModel { - val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage)) + val page = CreatePageWrapper(CreatePage(Randomizer.randomPageTitle(), body, published, frontPage, editingRoles)) return pagesService(token) .createCoursePage(courseId, page) diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SeedApi.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SeedApi.kt index 9536d92f91..98406ac1d3 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SeedApi.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/api/SeedApi.kt @@ -16,7 +16,11 @@ package com.instructure.dataseeding.api -import com.instructure.dataseeding.model.* +import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.dataseeding.model.DiscussionApiModel +import com.instructure.dataseeding.model.EnrollmentApiModel +import com.instructure.dataseeding.model.FavoriteApiModel // Data-seeding API object SeedApi { @@ -172,7 +176,7 @@ object SeedApi { with(seededData) { for (c in 0 until maxOf(request.courses + request.pastCourses, request.favoriteCourses)) { // Seed course - addCourses(createCourse(request.gradingPeriods, request.publishCourses)) + addCourses(createCourse(request.gradingPeriods, request.publishCourses, syllabusBody = request.syllabusBody)) // Seed users for (t in 0 until request.teachers) { @@ -316,7 +320,7 @@ object SeedApi { return if (accountId != null) { CoursesApi.createCourseInSubAccount(accountId = accountId, homeroomCourse = isHomeroomCourse, enrollmentTermId = enrollmentTermId, publish = publishCourses, syllabusBody = syllabusBody) } else { - CoursesApi.createCourse(enrollmentTermId, publishCourses) + CoursesApi.createCourse(enrollmentTermId, publishCourses, syllabusBody = syllabusBody) } } } \ No newline at end of file diff --git a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt index 64e8b17411..11f2496a76 100644 --- a/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt +++ b/automation/dataseedingapi/src/main/kotlin/com/instructure/dataseeding/model/PageApiModel.kt @@ -26,7 +26,9 @@ data class PageApiModel( val body: String, val published: Boolean, @SerializedName("front_page") - val frontPage: Boolean + val frontPage: Boolean, + @SerializedName("editing_roles") + val editingRoles: String ) data class CreatePage( @@ -34,7 +36,9 @@ data class CreatePage( val body: String, val published: Boolean, @SerializedName("front_page") - val frontPage: Boolean + val frontPage: Boolean, + @SerializedName("editing_roles") + val editingRoles: String? = null ) data class CreatePageWrapper( diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt index ae7ed73795..185d7aa675 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt @@ -296,11 +296,6 @@ abstract class CanvasTest : InstructureTestingContract { } } - // Does the test device have particularly low screen resolution? - fun isLowResDevice() : Boolean { - return activityRule.activity.resources.displayMetrics.densityDpi < DisplayMetrics.DENSITY_HIGH - } - fun isTabletDevice(): Boolean { val metrics = activityRule.activity.resources.displayMetrics @@ -482,6 +477,10 @@ abstract class CanvasTest : InstructureTestingContract { return getDeviceOrientation(ApplicationProvider.getApplicationContext()) == Configuration.ORIENTATION_PORTRAIT } + // Does the test device have particularly low screen resolution? + fun isLowResDevice() : Boolean { + return ApplicationProvider.getApplicationContext().resources.displayMetrics.densityDpi < DisplayMetrics.DENSITY_HIGH + } } } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt index e1d532d32f..001c0a9222 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt @@ -18,13 +18,20 @@ package com.instructure.canvas.espresso import android.util.DisplayMetrics import android.view.View +import android.view.ViewGroup import android.widget.RadioButton import android.widget.TextView import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.ViewInteraction import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.util.HumanReadables @@ -221,4 +228,63 @@ fun withOnlyWidthLessThan(dimInDp: Int) : BaseMatcher): Int { + var count = 0 + onView(viewMatcher).perform(object : ViewAction { + override fun getConstraints(): Matcher { + return isAssignableFrom(RecyclerView::class.java) + } + + override fun getDescription(): String { + return "Count RecyclerView children without ID" + } + + override fun perform(uiController: UiController?, view: View?) { + if (view is RecyclerView) { + val childCount = view.childCount + for (i in 0 until childCount) { + val child = view.getChildAt(i) + if (child.id == View.NO_ID) { + count++ + } + } + } + } + }) + return count +} + + +fun countConstraintLayoutsInRecyclerView(recyclerViewId: ViewInteraction): Int { + var count = 0 + recyclerViewId.perform(object : ViewAction { + override fun getConstraints(): Matcher { + return isAssignableFrom(RecyclerView::class.java) + } + + override fun getDescription(): String { + return "Counting ConstraintLayouts in RecyclerView" + } + + override fun perform(uiController: UiController, view: View) { + if (view is RecyclerView) { + count = countConstraintLayoutsInViewGroup(view) + } + } + }) + return count +} + +private fun countConstraintLayoutsInViewGroup(viewGroup: ViewGroup): Int { + var count = 0 + for (i in 0 until viewGroup.childCount) { + val child = viewGroup.getChildAt(i) + if (child is ConstraintLayout) { + count++ + } else if (child is ViewGroup) { + count += countConstraintLayoutsInViewGroup(child) + } + } + return count } \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt index 5eef33883e..cc3230943c 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/mockCanvas/MockCanvas.kt @@ -459,8 +459,15 @@ fun MockCanvas.updateUserEnrollments() { } } -fun MockCanvas.addCourseWithEnrollment(user: User, enrollmentType: Enrollment.EnrollmentType, score: Double = 0.0, grade: String = "", isHomeroom: Boolean = false): Course { - val course = addCourse(isHomeroom = isHomeroom) +fun MockCanvas.addCourseWithEnrollment( + user: User, + enrollmentType: Enrollment.EnrollmentType, + score: Double = 0.0, + grade: String = "", + isHomeroom: Boolean = false, + restrictQuantitativeData: Boolean = false +): Course { + val course = addCourse(isHomeroom = isHomeroom, restrictQuantitativeData = restrictQuantitativeData) addEnrollment( user = user, @@ -482,7 +489,8 @@ fun MockCanvas.addCourse( section: Section? = null, isPublic: Boolean = true, withGradingPeriod: Boolean = false, - isHomeroom: Boolean = false + isHomeroom: Boolean = false, + restrictQuantitativeData: Boolean = false ): Course { val randomCourseName = Randomizer.randomCourseName() val endAt = if (concluded) OffsetDateTime.now().minusWeeks(1).toApiString() else null @@ -508,7 +516,8 @@ fun MockCanvas.addCourse( homeroomCourse = isHomeroom, gradingPeriods = gradingPeriodList, courseColor = "#008EE2", - restrictEnrollmentsToCourseDate = concluded + restrictEnrollmentsToCourseDate = concluded, + settings = CourseSettings(restrictQuantitativeData = restrictQuantitativeData) ) courses += course.id to course @@ -943,7 +952,9 @@ fun MockCanvas.addSubmissionForAssignment( comment: SubmissionComment? = null, state: String = "submitted", grade: String? = null, - attempt: Long = 1 + attempt: Long = 1, + score: Double? = null, + excused: Boolean = false ) : Submission { val assignment = assignments[assignmentId]!! val assignmentDueDate = assignment.dueAt?.toDate() @@ -965,7 +976,11 @@ fun MockCanvas.addSubmissionForAssignment( attachments = if(attachment != null) arrayListOf(attachment) else arrayListOf(), submissionComments = if(comment != null) listOf(comment) else listOf(), mediaContentType = attachment?.contentType, - grade = grade + grade = grade, + score = score ?: 0.0, + postedAt = Date(), + excused = excused, + enteredScore = score ?: 0.0, ) // Get the submission list for the assignment, creating it if necessary @@ -993,7 +1008,11 @@ fun MockCanvas.addSubmissionForAssignment( attachments = if(attachment != null) arrayListOf(attachment) else arrayListOf(), submissionComments = if(comment != null) listOf(comment) else listOf(), mediaContentType = attachment?.contentType, - grade = grade + grade = grade, + score = score ?: 0.0, + postedAt = Date(), + excused = excused, + enteredScore = score ?: 0.0, ) submissionList.add(userRootSubmission) } @@ -1474,7 +1493,8 @@ fun MockCanvas.addItemToModule( course: Course, moduleId: Long, item: Any, - published: Boolean = true + published: Boolean = true, + moduleContentDetails: ModuleContentDetails? = null ) : ModuleItem { // Placeholders for itemType and itemTitle values that we will compute below @@ -1536,7 +1556,8 @@ fun MockCanvas.addItemToModule( // I don't really know if these two should be the same, but I needed // htmlUrl populated in order to get external url module items to work. url = itemUrl, - htmlUrl = itemUrl + htmlUrl = itemUrl, + moduleDetails = moduleContentDetails ) // Copy/update/replace the module @@ -1564,7 +1585,8 @@ fun MockCanvas.addQuizToCourse( dueAt: String? = null, published: Boolean = true, lockAt: String? = null, - unlockAt: String? = null + unlockAt: String? = null, + pointsPossible: Int? = null ) : Quiz { val quizId = newItemId() val quizUrl = "https://mock-data.instructure.com/api/v1/courses/${course.id}/quizzes/$quizId" @@ -1589,21 +1611,21 @@ fun MockCanvas.addQuizToCourse( } val result = Quiz( - id = quizId, - title = title, - description = description, - quizType = quizType, - mobileUrl = quizUrl, - htmlUrl = quizUrl, - timeLimit = timeLimitSecs, - dueAt = dueAt, - published = published, - assignmentId = assignment?.id ?: 0, - lockAt = lockAt, - unlockAt = unlockAt, - allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)) - - ) + id = quizId, + title = title, + description = description, + quizType = quizType, + mobileUrl = quizUrl, + htmlUrl = quizUrl, + timeLimit = timeLimitSecs, + dueAt = dueAt, + published = published, + assignmentId = assignment?.id ?: 0, + lockAt = lockAt, + unlockAt = unlockAt, + allDates = listOf(AssignmentDueDate(id = newItemId(), dueAt = dueAt, lockAt = lockAt, unlockAt = unlockAt)), + pointsPossible = pointsPossible?.toString() + ) var quizList = courseQuizzes[course.id] if(quizList == null) { @@ -1961,37 +1983,42 @@ private val canvaDocInk = CanvaDocInkList( * Consider doing this automatically whenever a submission is processed? */ fun MockCanvas.addSubmissionStreamItem( - user: User, - course: Course, - assignment: Assignment, - submission: Submission, - submittedAt: String? = null, - message: String = Faker.instance().lorem().sentence(), - type : String = "submission" -) : StreamItem { + user: User, + course: Course, + assignment: Assignment, + submission: Submission, + submittedAt: String? = null, + message: String = Faker.instance().lorem().sentence(), + type: String = "submission", + score: Double = -1.0, + grade: String? = null, + excused: Boolean = false +): StreamItem { // Create the StreamItem val item = StreamItem( - id = newItemId(), - course_id = course.id, - assignment_id = assignment.id, - title = assignment.name, - message = message, - assignment = assignment, - type = type, - submittedAt = submittedAt, - userId = user.id, - user = user, - updatedAt = submittedAt ?: "", - htmlUrl = "https://$domain/courses/${course.id}/assignments/${assignment.id}/submissions/${submission.id}", - context_type = CanvasContext.Type.USER.apiString - //canvasContext = user // This seems to break the notifications page so that it does not load - + id = newItemId(), + course_id = course.id, + assignment_id = assignment.id, + title = assignment.name, + message = message, + assignment = assignment, + type = type, + submittedAt = submittedAt, + userId = user.id, + user = user, + updatedAt = submittedAt ?: "", + htmlUrl = "https://$domain/courses/${course.id}/assignments/${assignment.id}/submissions/${submission.id}", + context_type = CanvasContext.Type.USER.apiString, + score = score, + grade = grade, + excused = excused + //canvasContext = user // This seems to break the notifications page so that it does not load ) // Record the StreamItem var list = streamItems[user.id] if (list == null) { - list = mutableListOf() + list = mutableListOf() streamItems[user.id] = list } list.add(item) diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index a6248e7e21..92a6c1f62d 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -23,9 +23,11 @@ import androidx.annotation.IdRes import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.matcher.ViewMatchers import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView +import junit.framework.AssertionFailedError import org.hamcrest.Matchers import org.junit.Assert.assertEquals @@ -74,3 +76,21 @@ class NotificationBadgeAssertion(@IdRes private val menuItemId: Int, private val assertEquals(badgeCount, expectedCount) } } + +class DoesNotExistAssertion(private val timeoutInSeconds: Long, private val pollIntervalInSeconds: Long = 1L) : ViewAssertion { + override fun check(view: View?, noViewFoundException: NoMatchingViewException?) { + var elapsedTime = 0L + + while (elapsedTime < timeoutInSeconds * 1000) { + try { + doesNotExist() + return + } catch (e: AssertionFailedError) { + Thread.sleep(pollIntervalInSeconds * 1000) + elapsedTime += (pollIntervalInSeconds * 1000) + } + } + + throw AssertionError("View still exists after $timeoutInSeconds seconds.") + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt new file mode 100644 index 0000000000..b8d5902141 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/Searchable.kt @@ -0,0 +1,25 @@ +package com.instructure.espresso + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView + +class Searchable(private val searchButtonId: Int? = null, private val queryInputId: Int? = null, private val clearButtonId: Int? = null, private val backButtonId: Int? = null) { + + fun clickOnSearchButton() { + onView(searchButtonId?.let { withId(it) }).click() + } + + fun typeToSearchBar(textToType: String) { + onView(queryInputId?.let { withId(it) }).perform(ViewActions.replaceText(textToType)) + } + + fun clickOnClearSearchButton() { + waitForView(clearButtonId?.let { withId(it) }).click() + } + + fun pressSearchBackButton() { + onView(backButtonId?.let { withId(it) }).click() + } +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index d9651d75b7..254db9d454 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -15,6 +15,10 @@ */ package com.instructure.espresso +import android.os.Build +import androidx.annotation.RequiresApi +import org.apache.commons.lang3.StringUtils +import java.time.LocalDateTime import java.util.* private val RANDOM = Random() @@ -28,3 +32,21 @@ fun randomString(length: Int = 20): String = StringBuilder().apply { fun randomDouble(length: Int = 8): Double = StringBuilder().apply { repeat(length) { append(DIGITS[RANDOM.nextInt(DIGITS.length)]) } }.toString().toDouble() + +fun capitalizeFirstLetter(inputText: String): String { + return if (inputText.isNotEmpty()) { + val firstLetter = inputText.substring(0, 1).uppercase() + val restOfWord = inputText.substring(1).lowercase() + firstLetter + restOfWord + } else StringUtils.EMPTY +} + + +@RequiresApi(Build.VERSION_CODES.O) +fun getCurrentDateInCanvasFormat(): String { + val expectedDate = LocalDateTime.now() + val monthString = capitalizeFirstLetter(expectedDate.month.name.take(3)) + val dayString = expectedDate.dayOfMonth + val yearString = expectedDate.year + return "$monthString $dayString, $yearString" +} \ No newline at end of file diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt index c31c9a5dd9..0d543a6b25 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt @@ -41,9 +41,9 @@ object WaitForViewMatcher { // https://github.com/braintree/braintree_android/blob/25513d76da88fe2ce9f476c4dc51f24cf6e26104/TestUtils/src/main/java/com/braintreepayments/testutils/ui/ViewHelper.java#L30 // The viewMatcher is called on every view to determine what matches. Must be fast! - fun waitForView(viewMatcher: Matcher, duration: Long = 10): ViewInteraction { + fun waitForView(viewMatcher: Matcher?, duration: Long = 10): ViewInteraction { log.i("Wait for View to be visible.") - return waitForViewWithCustomMatcher(viewMatcher, duration, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)) + return waitForViewWithCustomMatcher(viewMatcher!!, duration, withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)) } fun waitForViewToBeClickable(viewMatcher: Matcher, duration: Long = 10): ViewInteraction { diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt index b0c51d9431..cae3a84948 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/page/PageExtensions.kt @@ -42,6 +42,8 @@ fun BasePage.withId(id: Int): Matcher = ViewMatchers.withId(id) fun BasePage.withParent(id: Int): Matcher = ViewMatchers.withParent(withId(id)) +fun BasePage.withParent(matcher: Matcher): Matcher = ViewMatchers.withParent(matcher) + fun BasePage.withAncestor(id: Int): Matcher = ViewMatchers.isDescendantOfA(withId(id)) fun BasePage.withAncestor(matcher: Matcher): Matcher = ViewMatchers.isDescendantOfA(matcher) diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 37cbaf25b6..27a98927ff 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -177,11 +177,11 @@ task jacocoFullReport(type: JacocoReport, group: 'Coverage reports') { final source = files(projects.jacocoReport.sourceDirectories) - additionalSourceDirs.setFrom source - sourceDirectories.setFrom source + additionalSourceDirs.setFrom(source) + sourceDirectories.setFrom (source) - classDirectories.setFrom files(projects.jacocoReport.classDirectories) - executionData.setFrom files(projects.jacocoReport.executionData) + classDirectories.setFrom(files(projects.jacocoReport.classDirectories)) + executionData.setFrom(files(projects.jacocoReport.executionData)) reports { html { @@ -193,10 +193,6 @@ task jacocoFullReport(type: JacocoReport, group: 'Coverage reports') { destination file('build/reports/jacoco/jacocoFullReport.csv') } } - - doFirst { - executionData.setFrom files(executionData.findAll { it.exists() }) - } } task jacocoFullCombinedReport(type: JacocoReport, group: 'Coverage reports') { @@ -229,8 +225,4 @@ task jacocoFullCombinedReport(type: JacocoReport, group: 'Coverage reports') { destination file('build/reports/jacoco/jacocoFullCombinedReport.csv') } } - - doFirst { - executionData.setFrom files(executionData.findAll { it.exists() }) - } } \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt index 8e0cacc940..9ebfd03b96 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/CourseAPI.kt @@ -39,9 +39,12 @@ object CourseAPI { @get:GET("dashboard/dashboard_cards") val dashboardCourses: Call> - @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available") + @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&include[]=settings&state[]=completed&state[]=available") val firstPageCourses: Call> + @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&include[]=settings&state[]=completed&state[]=available&include[]=grading_scheme") + val firstPageCoursesWithGradingScheme: Call> + @GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available") suspend fun getFirstPageCourses(@Tag params: RestParams): DataResult> @@ -51,13 +54,13 @@ object CourseAPI { @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=current_and_concluded") val firstPageCoursesWithConcluded: Call> - @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&include[]=observed_users") + @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&include[]=observed_users&include[]=settings&include[]=grading_scheme") val firstPageCoursesWithSyllabus: Call> @get:GET("courses?include[]=term&include[]=syllabus_body&include[]=license&include[]=is_public&include[]=permissions&enrollment_state=active") val firstPageCoursesWithSyllabusWithActiveEnrollment: Call> - @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=completed&state[]=available&state[]=unpublished") + @get:GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&include[]=settings&state[]=completed&state[]=available&state[]=unpublished") val firstPageCoursesTeacher: Call> @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=course_image") @@ -72,7 +75,7 @@ object CourseAPI { @GET("courses/{courseId}?include[]=syllabus_body&include[]=term&include[]=license&include[]=is_public&include[]=permissions") fun getCourseWithSyllabus(@Path("courseId") courseId: Long): Call - @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image") + @GET("courses/{courseId}?include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image&include[]=settings&include[]=grading_scheme") fun getCourseWithGrade(@Path("courseId") courseId: Long): Call @GET("courses?include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=sections&state[]=current_and_concluded") @@ -121,13 +124,13 @@ object CourseAPI { @GET("courses/{courseId}/rubrics/{rubricId}") fun getRubricSettings(@Path("courseId") courseId: Long, @Path("rubricId") rubricId: Long): Call - @GET("courses?include[]=total_scores&include[]=current_grading_period_scores&include[]=grading_periods&include[]=course_image&enrollment_state=active") + @GET("courses?include[]=total_scores&include[]=current_grading_period_scores&include[]=grading_periods&include[]=course_image&include[]=settings&enrollment_state=active") fun getFirstPageCoursesWithGrades(): Call> } @Throws(IOException::class) - fun getCoursesSynchronously(adapter: RestBuilder, params: RestParams): List? { - val firstPageResponse = adapter.build(CoursesInterface::class.java, params).firstPageCourses.execute() + fun getCoursesSynchronouslyWithGradingScheme(adapter: RestBuilder, params: RestParams): List? { + val firstPageResponse = adapter.build(CoursesInterface::class.java, params).firstPageCoursesWithGradingScheme.execute() return getCoursesRecursive(adapter, params, firstPageResponse, firstPageResponse.body()) } @@ -169,6 +172,10 @@ object CourseAPI { callback.addCall(adapter.build(CoursesInterface::class.java, params).firstPageCourses).enqueue(callback) } + fun getFirstPageCoursesWithGradingScheme(adapter: RestBuilder, callback: StatusCallback>, params: RestParams) { + callback.addCall(adapter.build(CoursesInterface::class.java, params).firstPageCoursesWithGradingScheme).enqueue(callback) + } + fun getFirstPageCoursesWithConcluded(adapter: RestBuilder, callback: StatusCallback>, params: RestParams) { callback.addCall(adapter.build(CoursesInterface::class.java, params).firstPageCoursesWithConcluded).enqueue(callback) } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FeaturesAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FeaturesAPI.kt index 94cbd04e96..047fdf050a 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FeaturesAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FeaturesAPI.kt @@ -22,18 +22,23 @@ import com.instructure.canvasapi2.StatusCallback import com.instructure.canvasapi2.builders.RestBuilder import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.utils.APIHelper +import com.instructure.canvasapi2.utils.DataResult import retrofit2.Call import retrofit2.http.GET import retrofit2.http.Path +import retrofit2.http.Tag object FeaturesAPI { - internal interface FeaturesInterface { + interface FeaturesInterface { @GET("courses/{courseId}/features/enabled") fun getEnabledFeaturesForCourse(@Path("courseId") contextId: Long): Call> @GET("features/environment") fun getEnvironmentFeatureFlags(): Call> + + @GET("features/environment") + suspend fun getEnvironmentFeatureFlags(@Tag restParams: RestParams): DataResult> } fun getEnabledFeaturesForCourse(adapter: RestBuilder, courseId: Long, callback: StatusCallback>, params: RestParams) { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt new file mode 100644 index 0000000000..854320dd90 --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileDownloadAPI.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.canvasapi2.apis + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url +import java.io.File + +interface FileDownloadAPI { + + @Streaming + @GET + suspend fun downloadFile(@Url url: String): ResponseBody +} + +sealed class DownloadState { + data class InProgress(val progress: Int) : DownloadState() + object Success : DownloadState() + data class Failure(val throwable: Throwable) : DownloadState() +} + +fun ResponseBody.saveFile(file: File): Flow { + val debounce = 500L + + return flow { + emit(DownloadState.InProgress(0)) + var lastUpdate = System.currentTimeMillis() + try { + byteStream().use { inputStream -> + file.outputStream().use { outputStream -> + val totalBytes = contentLength() + val buffer = ByteArray(8 * 1024) + var progressBytes = 0L + var bytes = inputStream.read(buffer) + + while (bytes >= 0) { + outputStream.write(buffer, 0, bytes) + progressBytes += bytes + bytes = inputStream.read(buffer) + + if (System.currentTimeMillis() - lastUpdate > debounce) { + emit(DownloadState.InProgress((progressBytes * 100 / totalBytes).toInt())) + lastUpdate = System.currentTimeMillis() + } + } + } + } + emit(DownloadState.Success) + } catch (e: Exception) { + emit(DownloadState.Failure(e)) + } + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt index 8ac553e2fe..1937796805 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/di/ApiModule.kt @@ -1,6 +1,8 @@ package com.instructure.canvasapi2.di import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.FeaturesAPI +import com.instructure.canvasapi2.apis.FileDownloadAPI import com.instructure.canvasapi2.apis.GroupAPI import com.instructure.canvasapi2.apis.HelpLinksAPI import com.instructure.canvasapi2.apis.InboxApi @@ -168,4 +170,14 @@ class ApiModule { fun provideProgressApi(): ProgressAPI.ProgressInterface { return RestBuilder().build(ProgressAPI.ProgressInterface::class.java, RestParams()) } + + @Provides + fun provideFeaturesApi(): FeaturesAPI.FeaturesInterface { + return RestBuilder().build(FeaturesAPI.FeaturesInterface::class.java, RestParams()) + } + + @Provides + fun provideFileDownloadApi(): FileDownloadAPI { + return RestBuilder().build(FileDownloadAPI::class.java, RestParams()) + } } \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/CourseManager.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/CourseManager.kt index 5283bade91..ea621abb56 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/CourseManager.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/managers/CourseManager.kt @@ -69,6 +69,25 @@ object CourseManager { CourseAPI.getFirstPageCourses(adapter, depaginatedCallback, params) } + fun getCoursesWithGradingScheme(forceNetwork: Boolean, callback: StatusCallback>) { + if (ApiPrefs.isStudentView) { + getCoursesTeacher(forceNetwork, callback) + return + } + + val adapter = RestBuilder(callback) + val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceNetwork) + + val depaginatedCallback = object : ExhaustiveListCallback(callback) { + override fun getNextPage(callback: StatusCallback>, nextUrl: String, isCached: Boolean) { + CourseAPI.getNextPageCourses(forceNetwork, nextUrl, adapter, callback) + } + } + + adapter.statusCallback = depaginatedCallback + CourseAPI.getFirstPageCoursesWithGradingScheme(adapter, depaginatedCallback, params) + } + fun getCoursesWithConcluded(forceNetwork: Boolean, callback: StatusCallback>) { if (ApiPrefs.isStudentView) { getCoursesTeacher(forceNetwork, callback) @@ -341,11 +360,11 @@ object CourseManager { } @Throws(IOException::class) - fun getCoursesSynchronous(forceNetwork: Boolean): List { + fun getCoursesSynchronousWithGradingScheme(forceNetwork: Boolean): List { val adapter = RestBuilder() val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceNetwork) - val data = CourseAPI.getCoursesSynchronously(adapter, params) + val data = CourseAPI.getCoursesSynchronouslyWithGradingScheme(adapter, params) return data ?: ArrayList() } diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt index 236e01b8bf..7ac80447ba 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Assignment.kt @@ -161,6 +161,12 @@ data class Assignment( } + val isGradingTypeQuantitative: Boolean + get() { + val gradingType = getGradingTypeFromAPIString(this.gradingType ?: "") + return gradingType == GradingType.PERCENT || gradingType == GradingType.POINTS + } + enum class SubmissionType(val apiString: String) { ONLINE_QUIZ("online_quiz"), NONE("none"), diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt index 6aebf29621..13a694a693 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Course.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.utils.isCreationPending import com.instructure.canvasapi2.utils.isNullOrEmpty import com.instructure.canvasapi2.utils.toDate import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue import java.util.* @Parcelize @@ -78,7 +79,11 @@ data class Course( @SerializedName("course_color") val courseColor: String? = null, @SerializedName("grading_periods") - val gradingPeriods: List? = null + val gradingPeriods: List? = null, + @SerializedName("settings") + val settings: CourseSettings? = null, + @SerializedName("grading_scheme") + val gradingSchemeRaw: List>? = null, ) : CanvasContext(), Comparable { override val type: Type get() = Type.COURSE @@ -126,6 +131,17 @@ data class Course( return false } + val gradingScheme: List + get() { + return gradingSchemeRaw?.map { row -> + if (row.size < 2 || row[0] !is String || row[1] !is Double) { + null + } else { + GradingSchemeRow(row[0] as String, row[1] as Double) + } + }?.filterNotNull()?.sortedByDescending { it.value } ?: emptyList() + } + /** * A helper method to get access to all course grade values in one place and how to display them * diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt index 583a49ca89..4dc4d5cc09 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/CourseSettings.kt @@ -15,9 +15,14 @@ */ package com.instructure.canvasapi2.models +import android.os.Parcelable import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +@Parcelize data class CourseSettings( @SerializedName("syllabus_course_summary") - var courseSummary: Boolean? = null -) + var courseSummary: Boolean? = null, + @SerializedName("restrict_quantitative_data") + var restrictQuantitativeData: Boolean = false, +): Parcelable diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt index a55a96b851..38da34b47e 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/Enrollment.kt @@ -45,6 +45,8 @@ data class Enrollment( val computedCurrentGrade: String? = null, @SerializedName("computed_final_grade") val computedFinalGrade: String? = null, + @SerializedName("computed_current_letter_grade") + val computedCurrentLetterGrade: String? = null, @SerializedName("multiple_grading_periods_enabled") val multipleGradingPeriodsEnabled: Boolean = false, @SerializedName("totals_for_all_grading_periods_option") @@ -103,7 +105,7 @@ data class Enrollment( val currentScore: Double? get() = grades?.currentScore ?: computedCurrentScore val finalScore: Double? get() = grades?.finalScore ?: computedFinalScore - val currentGrade: String? get() = grades?.currentGrade ?: computedCurrentGrade + val currentGrade: String? get() = grades?.currentGrade ?: computedCurrentGrade ?: computedCurrentLetterGrade val finalGrade: String? get() = grades?.finalGrade ?: computedFinalGrade fun currentPeriodComputedCurrentScore(): Double? = grades?.currentScore ?: currentPeriodComputedCurrentScore diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/GradingSchemeRow.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/GradingSchemeRow.kt new file mode 100644 index 0000000000..cdab07e260 --- /dev/null +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/GradingSchemeRow.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.canvasapi2.models + +data class GradingSchemeRow(val name: String, val value: Double) \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt index 7a826b4c4a..dc5a269441 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/models/StreamItem.kt @@ -21,11 +21,11 @@ import android.content.Context import com.google.gson.annotations.SerializedName import com.instructure.canvasapi2.R import com.instructure.canvasapi2.utils.APIHelper +import com.instructure.canvasapi2.utils.convertScoreToLetterGrade import com.instructure.canvasapi2.utils.toDate import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.ArrayList -import java.util.Locale +import java.util.* @Parcelize data class StreamItem( @@ -93,7 +93,8 @@ data class StreamItem( val assignment: Assignment? = null, @SerializedName("user_id") val userId: Long = -1, - val user: User = User() + val user: User = User(), + val excused: Boolean = false ) : CanvasModel() { // We want opposite of natural sorting order of date since we want the newest one to come first override val comparisonDate get() = updatedDate @@ -178,9 +179,9 @@ data class StreamItem( return title } - fun getMessage(context: Context): String? { + fun getMessage(context: Context, restrictQuantitativeData: Boolean = false, gradingScheme: List = emptyList()): String? { if (message == null) { - message = createMessage(context) + message = createMessage(context, restrictQuantitativeData, gradingScheme) } return message } @@ -214,7 +215,7 @@ data class StreamItem( } } - private fun createMessage(context: Context): String? { + private fun createMessage(context: Context, restrictQuantitativeData: Boolean = false, gradingScheme: List = emptyList()): String? { when (getStreamItemType()) { StreamItem.Type.CONVERSATION -> { if (conversation == null) { @@ -226,18 +227,20 @@ data class StreamItem( } StreamItem.Type.SUBMISSION -> { // Get comments from assignment - var comment: String? = null + var comment: String = "" if (submissionComments.isNotEmpty()) { - comment = submissionComments[submissionComments.size - 1].comment + val lastComment = submissionComments.last().comment + if (lastComment != null && lastComment != "null") comment = lastComment } - // Set it to the last comment if it's not null - if (comment != null && comment != "null" && score != -1.0) { - return ":$score $comment" - } else if ((comment == null || comment == "null") && score != -1.0) { - return ":$score" - } else if (comment != null && comment != "null" && score == -1.0) { - return comment + + val displayedGrade = when { + excused -> context.getString(R.string.gradeExcused) + restrictQuantitativeData -> getGradeWhenQuantitativeDataRestricted(context, gradingScheme, score, assignment?.pointsPossible) + score != -1.0 -> score.toString().orEmpty() + else -> "" } + + return "$displayedGrade $comment" } StreamItem.Type.DISCUSSION_TOPIC -> // If it's a discussionTopic, get the last entry for the message. @@ -253,6 +256,18 @@ data class StreamItem( } else message } + private fun getGradeWhenQuantitativeDataRestricted(context: Context, gradingScheme: List, score: Double, maxScore: Double?): String { + return if (assignment?.isGradingTypeQuantitative == true) { + if (gradingScheme.isEmpty() || maxScore == null) { + context.getString(R.string.gradeUpdated) + } else { + convertScoreToLetterGrade(score, maxScore, gradingScheme) + } + } else { + grade.orEmpty() + } + } + private fun parseAssignmentId(): Long { // Get the assignment from the url if (htmlUrl.isNotEmpty() && htmlUrl != "null") { diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt index dd75aaccc5..80485eddc5 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ModelExtensions.kt @@ -174,4 +174,16 @@ fun Assignment.toScheduleItem() : ScheduleItem { assignment = this, lockedModuleName = lockInfo?.lockedModuleName ).apply { courseId = id } +} + +fun convertScoreToLetterGrade(score: Double, maxScore: Double, gradingScheme: List): String { + if (maxScore == 0.0) return "" + val percent = (score / maxScore) + return convertPercentScoreToLetterGrade(percent, gradingScheme) +} + +fun convertPercentScoreToLetterGrade(percentScore: Double, gradingScheme: List): String { + if (gradingScheme.isEmpty()) return "" + val grade = gradingScheme.firstOrNull { percentScore >= it.value } ?: gradingScheme.last() + return grade.name } \ No newline at end of file diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt index bf2b1f3842..17b9a342ab 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/pact/canvas/apis/CoursesApiPactTests.kt @@ -102,7 +102,7 @@ class CoursesApiPactTests : ApiPactTestBase() { //region Test grabbing all courses // - val allCoursesQuery = "include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&state[]=completed&state[]=available" + val allCoursesQuery = "include[]=term&include[]=total_scores&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=permissions&include[]=favorites&include[]=current_grading_period_scores&include[]=course_image&include[]=banner_image&include[]=sections&include[]=settings&state[]=completed&state[]=available" val allCoursesPath = "/api/v1/courses" val allCoursesFieldInfo = listOf( // Evidently, permissions info is *not* returned from this call, even though include[]=permissions is specified @@ -210,7 +210,7 @@ class CoursesApiPactTests : ApiPactTestBase() { //region Test grabbing a single course with a grade // - val courseWithGradeQuery = "include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image" + val courseWithGradeQuery = "include[]=term&include[]=permissions&include[]=license&include[]=is_public&include[]=needs_grading_count&include[]=total_scores&include[]=current_grading_period_scores&include[]=course_image&include[]=settings&include[]=grading_scheme" val courseWithGradePath = "/api/v1/courses/3" val courseWithGradeFieldInfo = PactCourseFieldConfig.fromQueryString(courseId = 3, isFavorite = true, query = courseWithGradeQuery) val courseWithGradeResponseBody = LambdaDsl.newJsonBody { obj -> diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/CourseTest.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/CourseTest.kt index 6e0cd09412..94284e772f 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/CourseTest.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/unit/CourseTest.kt @@ -19,6 +19,7 @@ package com.instructure.canvasapi2.unit import com.instructure.canvasapi2.models.Course import com.instructure.canvasapi2.models.Enrollment +import com.instructure.canvasapi2.models.GradingSchemeRow import com.instructure.canvasapi2.models.Section import com.instructure.canvasapi2.models.Term import com.instructure.canvasapi2.utils.Logger @@ -562,4 +563,22 @@ class CourseTest { assertTrue(course.isBetweenValidDateRange()) } + + @Test + fun `Grading schemes are sorted and filtered correctly`() { + val course = baseCourse.copy(gradingSchemeRaw = listOf( + listOf("A", 0.95), + listOf("C", 0.7), + listOf(0.8), + listOf("B", 0.9), + listOf("A", "B") + )) + + val gradingSchemes = course.gradingScheme + + assertEquals(3, gradingSchemes.size) + assertEquals(GradingSchemeRow("A", 0.95), gradingSchemes[0]) + assertEquals(GradingSchemeRow("B", 0.9), gradingSchemes[1]) + assertEquals(GradingSchemeRow("C", 0.7), gradingSchemes[2]) + } } \ No newline at end of file diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt new file mode 100644 index 0000000000..c50f1307f0 --- /dev/null +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/ModelExtensionsTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.canvasapi2.utils + +import com.instructure.canvasapi2.models.GradingSchemeRow +import org.junit.Assert +import org.junit.Assert.* +import org.junit.Test + +class ModelExtensionsTest { + + private val gradingSchemes = listOf( + GradingSchemeRow("A", 0.95), + GradingSchemeRow("B", 0.9), + GradingSchemeRow("C", 0.8), + GradingSchemeRow("D", 0.7), + GradingSchemeRow("F", 0.6) + ) + + @Test + fun `Score to letter grade returns empty string when max score is 0`() { + val result = convertScoreToLetterGrade(1.0, 0.0, gradingSchemes) + + assertEquals("", result) + } + + @Test + fun `Score to letter grade returns empty string when grading schemes is empty`() { + val result = convertScoreToLetterGrade(1.0, 10.0, emptyList()) + + assertEquals("", result) + } + + @Test + fun `Score to letter grade returns last item when nothing matches`() { + val result = convertScoreToLetterGrade(1.0, 10.0, gradingSchemes) + + assertEquals("F", result) + } + + @Test + fun `Score to letter grade returns last item when zero points`() { + val result = convertScoreToLetterGrade(0.0, 10.0, gradingSchemes) + + assertEquals("F", result) + } + + @Test + fun `Score to letter grade returns first item when overgraded`() { + val result = convertScoreToLetterGrade(15.0, 10.0, gradingSchemes) + + assertEquals("A", result) + } + + @Test + fun `Score to letter grade returns items inclusively`() { + val result = convertScoreToLetterGrade(95.0, 100.0, gradingSchemes) + + assertEquals("A", result) + } + + @Test + fun `Score to letter grade returns correct items with more decimal places`() { + val result = convertScoreToLetterGrade(94.9999, 100.0, gradingSchemes) + + assertEquals("B", result) + } + + @Test + fun `Score to letter grade returns the last value with negatice score`() { + val result = convertScoreToLetterGrade(-50.0, 100.0, gradingSchemes) + + assertEquals("F", result) + } + + @Test + fun `Score to letter grade returns C for 80 percent`() { + val result = convertScoreToLetterGrade(80.0, 100.0, gradingSchemes) + + assertEquals("C", result) + } + + @Test + fun `Score to letter grade returns D for 70 percent`() { + val result = convertScoreToLetterGrade(70.0, 100.0, gradingSchemes) + + assertEquals("D", result) + } + + @Test + fun `Convert percent to letter grade returns empty string when no grading schemes is empty`() { + val result = convertPercentScoreToLetterGrade(1.0, emptyList()) + + assertEquals("", result) + } + + @Test + fun `Convert percent to letter grade returns last item when nothing matches`() { + val result = convertPercentScoreToLetterGrade( 0.1, gradingSchemes) + + assertEquals("F", result) + } +} \ No newline at end of file diff --git a/libs/flutter_student_embed/lib/l10n/res/intl_en.arb b/libs/flutter_student_embed/lib/l10n/res/intl_en.arb index 428ebdf814..451f221a09 100644 --- a/libs/flutter_student_embed/lib/l10n/res/intl_en.arb +++ b/libs/flutter_student_embed/lib/l10n/res/intl_en.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2023-04-14T11:04:56.872320", + "@@last_modified": "2023-08-25T11:04:30.842905", "coursesLabel": "Courses", "@coursesLabel": { "description": "The label for the Courses tab", diff --git a/libs/flutter_student_embed/lib/l10n/res/intl_messages.arb b/libs/flutter_student_embed/lib/l10n/res/intl_messages.arb index 428ebdf814..451f221a09 100644 --- a/libs/flutter_student_embed/lib/l10n/res/intl_messages.arb +++ b/libs/flutter_student_embed/lib/l10n/res/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2023-04-14T11:04:56.872320", + "@@last_modified": "2023-08-25T11:04:30.842905", "coursesLabel": "Courses", "@coursesLabel": { "description": "The label for the Courses tab", diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt index c1b7f0687f..d236a8a53b 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/activities/BaseLoginLandingPageActivity.kt @@ -20,6 +20,7 @@ import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.content.Intent import android.content.pm.ApplicationInfo +import android.content.res.ColorStateList import android.os.Bundle import android.view.GestureDetector import android.view.MotionEvent @@ -62,6 +63,7 @@ import com.instructure.loginapi.login.util.LoginPrefs import com.instructure.loginapi.login.util.PreviousUsersUtils import com.instructure.loginapi.login.util.SavedLoginInfo import com.instructure.loginapi.login.viewmodel.LoginViewModel +import com.instructure.pandautils.binding.setTint import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.utils.* import java.util.* @@ -220,7 +222,7 @@ abstract class BaseLoginLandingPageActivity : AppCompatActivity(), ErrorReportDi ColorUtils.colorIt(color, canvasLogo) // App Name/Type. Will not be present in all layout versions - appDescriptionType.setTextColor(color) + canvasWordmark.imageTintList = ColorStateList.valueOf(color) appDescriptionType.setText(appTypeName()) ViewStyler.themeStatusBar(this@BaseLoginLandingPageActivity) diff --git a/libs/login-api-2/src/main/res/layout-land/activity_login_landing_page.xml b/libs/login-api-2/src/main/res/layout-land/activity_login_landing_page.xml index a2b8b7a589..a9de871d69 100644 --- a/libs/login-api-2/src/main/res/layout-land/activity_login_landing_page.xml +++ b/libs/login-api-2/src/main/res/layout-land/activity_login_landing_page.xml @@ -55,7 +55,7 @@ android:layout_marginBottom="2dp" android:adjustViewBounds="true" android:importantForAccessibility="no" - android:tint="@color/tiara" + android:tint="@color/login_teacherAppTheme" app:srcCompat="@drawable/ic_canvas_wordmark" /> diff --git a/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml b/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml index b41bb274f1..d1e37b99ca 100644 --- a/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml +++ b/libs/login-api-2/src/main/res/layout/activity_login_landing_page.xml @@ -55,8 +55,8 @@ android:layout_marginTop="24dp" android:layout_marginBottom="2dp" android:adjustViewBounds="true" - android:importantForAccessibility="no" - android:tint="@color/tiara" + android:contentDescription="@string/canvas" + android:tint="@color/login_teacherAppTheme" app:srcCompat="@drawable/ic_canvas_wordmark" /> diff --git a/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml b/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml index d42dbae34d..380e298d7c 100644 --- a/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml +++ b/libs/login-api-2/src/main/res/layout/adapter_account_footer.xml @@ -44,6 +44,6 @@ android:textColor="@color/textInfo" android:fontFamily="@font/lato_font_family" android:textSize="16sp" - android:text="@string/accountDomainFooterLink"/> + android:text="@string/accountDomainFooterLoginHelp"/> \ No newline at end of file diff --git a/libs/login-api-2/src/main/res/values/strings.xml b/libs/login-api-2/src/main/res/values/strings.xml index 4acc7c102b..493fa8837d 100644 --- a/libs/login-api-2/src/main/res/values/strings.xml +++ b/libs/login-api-2/src/main/res/values/strings.xml @@ -112,7 +112,7 @@ F school.instructure.com Remove Previous User Can\'t find your school? Try typing the full school URL. - Tap here for help. + Tap here for login help. No Internet Connection This action requires an internet connection. diff --git a/libs/pandares/src/main/res/drawable/ic_rating_star.xml b/libs/pandares/src/main/res/drawable/ic_rating_star.xml new file mode 100644 index 0000000000..0c50640422 --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_rating_star.xml @@ -0,0 +1,5 @@ + + + diff --git a/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml b/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml new file mode 100644 index 0000000000..c32749453d --- /dev/null +++ b/libs/pandares/src/main/res/drawable/ic_rating_star_outline.xml @@ -0,0 +1,5 @@ + + + diff --git a/libs/pandares/src/main/res/values-ar/strings.xml b/libs/pandares/src/main/res/values-ar/strings.xml index 81c3003445..6aefe0cf01 100644 --- a/libs/pandares/src/main/res/values-ar/strings.xml +++ b/libs/pandares/src/main/res/values-ar/strings.xml @@ -455,6 +455,7 @@ تم الحذف + تم تحديث الدرجة جارٍ تحميل محتوى Canvas… UnknownDevice @@ -1170,6 +1171,7 @@ يمكنك فتح تفاصيل الإرسال من هنا + الدرجة: %s %s من الدقائق %s دقيقة diff --git a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml index e1651e56b6..3d89c5ace5 100644 --- a/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+da+DK+instk12/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering opdateret Indlæser Canvas-indhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åbne afleveringsdetaljerne herfra + Vurdering: %s %s minut %s minutter diff --git a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml index 9d91414183..013c458795 100644 --- a/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+AU+unimelb/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml index 4072036e2e..e860a4f857 100644 --- a/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml +++ b/libs/pandares/src/main/res/values-b+en+GB+instukhe/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml index bd6ae8ec18..3bbbfdbd80 100644 --- a/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+nb+NO+instk12/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering oppdatert Laster Canvas-innhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åpne Detaljer om innlevering her + Vurdering: %s %s minutt %s minutter diff --git a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml index 44ae3284e8..eb7b807e44 100644 --- a/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml +++ b/libs/pandares/src/main/res/values-b+sv+SE+instk12/strings.xml @@ -432,6 +432,7 @@ Borttagen + Bedömning uppdaterat Läser in Canvas-innehåll… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan öppna inlämningsinformationen härifrån + Bedömning: %s %s minut %s minuter diff --git a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml index b6bf168689..ee91fbdcd0 100644 --- a/libs/pandares/src/main/res/values-b+zh+HK/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+HK/strings.xml @@ -426,6 +426,7 @@ 已刪除 + 評分已更新 載入 Canvas 內容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以從此處開啟提交項目詳細資料 + 評分:%s %s 分鐘 diff --git a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml index 815e304d01..9fd0ff9c21 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hans/strings.xml @@ -426,6 +426,7 @@ 已删除 + 评分已更新 正在加载Canvas内容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以从此处打开提交详情 + 评分:%s %s 分钟 diff --git a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml index b6bf168689..ee91fbdcd0 100644 --- a/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml +++ b/libs/pandares/src/main/res/values-b+zh+Hant/strings.xml @@ -426,6 +426,7 @@ 已刪除 + 評分已更新 載入 Canvas 內容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以從此處開啟提交項目詳細資料 + 評分:%s %s 分鐘 diff --git a/libs/pandares/src/main/res/values-ca/strings.xml b/libs/pandares/src/main/res/values-ca/strings.xml index 00d064fb12..013976b360 100644 --- a/libs/pandares/src/main/res/values-ca/strings.xml +++ b/libs/pandares/src/main/res/values-ca/strings.xml @@ -432,6 +432,7 @@ Suprimit + S’ha actualitzat la nota S\'està carregant el contingut de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Podeu obrir Informació de l’entrega des d\'aquí + Nota: %s %s minut %s minuts diff --git a/libs/pandares/src/main/res/values-cy/strings.xml b/libs/pandares/src/main/res/values-cy/strings.xml index 6bd48540a4..8498f8bf6d 100644 --- a/libs/pandares/src/main/res/values-cy/strings.xml +++ b/libs/pandares/src/main/res/values-cy/strings.xml @@ -432,6 +432,7 @@ Wedi dileu + Gradd wedi’i diweddaru Llwytho Cynnwys Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Gallwch chi agor manylion Cyflwyniad fan hyn + Gradd: %s %s Munud %s Munud diff --git a/libs/pandares/src/main/res/values-da/strings.xml b/libs/pandares/src/main/res/values-da/strings.xml index 5235e2943e..ddc3e5462f 100644 --- a/libs/pandares/src/main/res/values-da/strings.xml +++ b/libs/pandares/src/main/res/values-da/strings.xml @@ -432,6 +432,7 @@ Slettet + Karakter opdateret Indlæser Canvas-indhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åbne afleveringsdetaljerne herfra + Karakter: %s %s minut %s minutter diff --git a/libs/pandares/src/main/res/values-de/strings.xml b/libs/pandares/src/main/res/values-de/strings.xml index 97b9b7ade9..58cda0e850 100644 --- a/libs/pandares/src/main/res/values-de/strings.xml +++ b/libs/pandares/src/main/res/values-de/strings.xml @@ -432,6 +432,7 @@ Gelöscht + Note aktualisiert Canvas-Content laden… UnknownDevice @@ -1119,6 +1120,7 @@ Sie können die Abgabedetails von hier aus öffnen + Note: %s %s Minute %s Minuten diff --git a/libs/pandares/src/main/res/values-en-rAU/strings.xml b/libs/pandares/src/main/res/values-en-rAU/strings.xml index 94d73a8b46..8aa32c6d13 100644 --- a/libs/pandares/src/main/res/values-en-rAU/strings.xml +++ b/libs/pandares/src/main/res/values-en-rAU/strings.xml @@ -432,6 +432,7 @@ Deleted + Mark updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Mark: %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-en-rCY/strings.xml b/libs/pandares/src/main/res/values-en-rCY/strings.xml index 4072036e2e..e860a4f857 100644 --- a/libs/pandares/src/main/res/values-en-rCY/strings.xml +++ b/libs/pandares/src/main/res/values-en-rCY/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-en-rGB/strings.xml b/libs/pandares/src/main/res/values-en-rGB/strings.xml index 2b1b2629c7..70d3e97250 100644 --- a/libs/pandares/src/main/res/values-en-rGB/strings.xml +++ b/libs/pandares/src/main/res/values-en-rGB/strings.xml @@ -432,6 +432,7 @@ Deleted + Grade updated Loading Canvas Content… UnknownDevice @@ -1119,6 +1120,7 @@ You can open Submission details from here + Grade: %s %s minute %s Minutes diff --git a/libs/pandares/src/main/res/values-es-rES/strings.xml b/libs/pandares/src/main/res/values-es-rES/strings.xml index e8465b9bf7..8cef4f43c9 100644 --- a/libs/pandares/src/main/res/values-es-rES/strings.xml +++ b/libs/pandares/src/main/res/values-es-rES/strings.xml @@ -432,6 +432,7 @@ Eliminado + Nota actualizada Cargando el contenido de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Desde aquí puedes abrir los detalles de la entrega + Nota: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-es/strings.xml b/libs/pandares/src/main/res/values-es/strings.xml index 22bfecd1b7..1aaa81bfb7 100644 --- a/libs/pandares/src/main/res/values-es/strings.xml +++ b/libs/pandares/src/main/res/values-es/strings.xml @@ -432,6 +432,7 @@ Eliminado + Calificación actualizada Cargando el contenido de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Desde aquí puede abrir los detalles de Entrega + Calificación: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-fi/strings.xml b/libs/pandares/src/main/res/values-fi/strings.xml index d4f361ce9a..ab65479f50 100644 --- a/libs/pandares/src/main/res/values-fi/strings.xml +++ b/libs/pandares/src/main/res/values-fi/strings.xml @@ -432,6 +432,7 @@ Poistettu + Arvosana päivitetty Ladataan Canvas-sisältöä… UnknownDevice @@ -1119,6 +1120,7 @@ Voit avata tehtävän palautustiedot täältä + Arvosana: %s %s Minuutti %s minuuttia diff --git a/libs/pandares/src/main/res/values-fr-rCA/strings.xml b/libs/pandares/src/main/res/values-fr-rCA/strings.xml index 63e5e000cd..dd583f2abc 100644 --- a/libs/pandares/src/main/res/values-fr-rCA/strings.xml +++ b/libs/pandares/src/main/res/values-fr-rCA/strings.xml @@ -432,6 +432,7 @@ Supprimé + Note mise à jour Chargement du contenu de Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Vous pouvez ouvrir les détails de l’envoi à partir d’ici + Note : %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-fr/strings.xml b/libs/pandares/src/main/res/values-fr/strings.xml index a566c9618a..adaf919f50 100644 --- a/libs/pandares/src/main/res/values-fr/strings.xml +++ b/libs/pandares/src/main/res/values-fr/strings.xml @@ -432,6 +432,7 @@ Supprimé + Note mise à jour Chargement du contenu Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Vous pouvez ouvrir les Détails de soumission à partir d\'ici + Note : %s %s Minute %s Minutes diff --git a/libs/pandares/src/main/res/values-ht/strings.xml b/libs/pandares/src/main/res/values-ht/strings.xml index bad97880f0..4d9e1e3de6 100644 --- a/libs/pandares/src/main/res/values-ht/strings.xml +++ b/libs/pandares/src/main/res/values-ht/strings.xml @@ -432,6 +432,7 @@ Efase + Klas aktyalize Chajman Kontni Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Ou ka ouvri detay Soumisyon yo la a + Klas: %s %s Minit %s Minit diff --git a/libs/pandares/src/main/res/values-is/strings.xml b/libs/pandares/src/main/res/values-is/strings.xml index 11335d2433..71498f4b73 100644 --- a/libs/pandares/src/main/res/values-is/strings.xml +++ b/libs/pandares/src/main/res/values-is/strings.xml @@ -432,6 +432,7 @@ Eytt + Einkunn uppfærð Sæki Canvas efni… UnknownDevice @@ -1119,6 +1120,7 @@ Þú getur opnað upplýsingar um skil héðan + Einkunn: %s %s Mínúta %s mínútur diff --git a/libs/pandares/src/main/res/values-it/strings.xml b/libs/pandares/src/main/res/values-it/strings.xml index 95e35d513b..1ebe6ca67a 100644 --- a/libs/pandares/src/main/res/values-it/strings.xml +++ b/libs/pandares/src/main/res/values-it/strings.xml @@ -432,6 +432,7 @@ Eliminato + Voto aggiornato Caricamento dei contenuti Contenuto… UnknownDevice @@ -1119,6 +1120,7 @@ Puoi aprire i dettagli consegna da qui + Voto: %s %s minuto %s minuti diff --git a/libs/pandares/src/main/res/values-ja/strings.xml b/libs/pandares/src/main/res/values-ja/strings.xml index 44d2184638..0b26bf4991 100644 --- a/libs/pandares/src/main/res/values-ja/strings.xml +++ b/libs/pandares/src/main/res/values-ja/strings.xml @@ -426,6 +426,7 @@ 削除されました + 評定を更新しました Canvas コンテンツ…を読み込み中 UnknownDevice @@ -1106,6 +1107,7 @@ 「提出」の詳細はここで開くことができます + 評定:%s %s 分 diff --git a/libs/pandares/src/main/res/values-mi/strings.xml b/libs/pandares/src/main/res/values-mi/strings.xml index 31a742b7fb..3ce51d36f9 100644 --- a/libs/pandares/src/main/res/values-mi/strings.xml +++ b/libs/pandares/src/main/res/values-mi/strings.xml @@ -432,6 +432,7 @@ mukua + Koeke kua whakahoutia E uta ana Canvas Ihirangi… UnknownDevice @@ -1119,6 +1120,7 @@ Mai i konei, ka kite koe i nga Taipitopito Tukunga. + Kōeke: %s %s Meneti %s meneti diff --git a/libs/pandares/src/main/res/values-ms/strings.xml b/libs/pandares/src/main/res/values-ms/strings.xml index 22552f920e..bce19c3d3a 100644 --- a/libs/pandares/src/main/res/values-ms/strings.xml +++ b/libs/pandares/src/main/res/values-ms/strings.xml @@ -432,6 +432,7 @@ Telah Dipadamkan + Gred dikemas kini Memuatkan Kandungan Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Anda boleh membuka butiran Serahan di sini + Gred: %s %s Minit %s Minit diff --git a/libs/pandares/src/main/res/values-nb/strings.xml b/libs/pandares/src/main/res/values-nb/strings.xml index fc43bf719c..74ed00f0d0 100644 --- a/libs/pandares/src/main/res/values-nb/strings.xml +++ b/libs/pandares/src/main/res/values-nb/strings.xml @@ -432,6 +432,7 @@ Slettet + Vurdering oppdatert Laster Canvas-innhold… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan åpne Detaljer om innlevering her + Vurdering: %s %s minutt %s minutter diff --git a/libs/pandares/src/main/res/values-nl/strings.xml b/libs/pandares/src/main/res/values-nl/strings.xml index e5e50a7c83..0b32e79400 100644 --- a/libs/pandares/src/main/res/values-nl/strings.xml +++ b/libs/pandares/src/main/res/values-nl/strings.xml @@ -432,6 +432,7 @@ Verwijderd + Cijfer bijgewerkt Canvas Content aan het uploaden… UnknownDevice @@ -1119,6 +1120,7 @@ U kunt inleverdetails hier openen + Cijfer: %s %s minuut %s minuten diff --git a/libs/pandares/src/main/res/values-pl/strings.xml b/libs/pandares/src/main/res/values-pl/strings.xml index f40e84f984..a9f3d91551 100644 --- a/libs/pandares/src/main/res/values-pl/strings.xml +++ b/libs/pandares/src/main/res/values-pl/strings.xml @@ -444,6 +444,7 @@ Usunięto + Zaktualizowano ocenę Ładowanie zawartości Canvas… UnknownDevice @@ -1145,6 +1146,7 @@ Tutaj można otworzyć szczegóły przesyłki + Ocena: %s %s min %s min diff --git a/libs/pandares/src/main/res/values-pt-rBR/strings.xml b/libs/pandares/src/main/res/values-pt-rBR/strings.xml index ce7e963aba..dc5f3aec5c 100644 --- a/libs/pandares/src/main/res/values-pt-rBR/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rBR/strings.xml @@ -432,6 +432,7 @@ Excluído + Nota atualizada Carregando conteúdo do Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Você pode abrir os detalhes do Envio aqui + Nota: %s %s Minuto %s Minutos diff --git a/libs/pandares/src/main/res/values-pt-rPT/strings.xml b/libs/pandares/src/main/res/values-pt-rPT/strings.xml index 3c73a4d156..1bf5144981 100644 --- a/libs/pandares/src/main/res/values-pt-rPT/strings.xml +++ b/libs/pandares/src/main/res/values-pt-rPT/strings.xml @@ -432,6 +432,7 @@ Eliminado + Nota atualizada A carregar o conteúdo da tela… UnknownDevice @@ -1119,6 +1120,7 @@ Podes abrir detalhes de Submissão a partir daqui + Nota: %s %s minuto %s minutos diff --git a/libs/pandares/src/main/res/values-ru/strings.xml b/libs/pandares/src/main/res/values-ru/strings.xml index 311d8519e6..9c7d3ded27 100644 --- a/libs/pandares/src/main/res/values-ru/strings.xml +++ b/libs/pandares/src/main/res/values-ru/strings.xml @@ -444,6 +444,7 @@ Удалено + Оценка обновлена Загрузка контента Canvas… UnknownDevice @@ -1145,6 +1146,7 @@ Вы можете открыть информацию об отправке здесь + Оценка: %s %s минута %s минут diff --git a/libs/pandares/src/main/res/values-sl/strings.xml b/libs/pandares/src/main/res/values-sl/strings.xml index 3dd4497706..b77f4e12fc 100644 --- a/libs/pandares/src/main/res/values-sl/strings.xml +++ b/libs/pandares/src/main/res/values-sl/strings.xml @@ -432,6 +432,7 @@ Odstranjeno + Ocena je posodobljena Nalaganje vsebine sistema Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Tukaj lahko odprete podrobnosti o oddaji + Ocena: %s %s minuta %s minut diff --git a/libs/pandares/src/main/res/values-sv/strings.xml b/libs/pandares/src/main/res/values-sv/strings.xml index b923d00664..fc1aee1a9d 100644 --- a/libs/pandares/src/main/res/values-sv/strings.xml +++ b/libs/pandares/src/main/res/values-sv/strings.xml @@ -432,6 +432,7 @@ Borttagen + Omdöme uppdaterat Läser in Canvas-innehåll… UnknownDevice @@ -1119,6 +1120,7 @@ Du kan öppna inlämningsinformationen härifrån + Omdöme: %s %s minut %s minuter diff --git a/libs/pandares/src/main/res/values-th/strings.xml b/libs/pandares/src/main/res/values-th/strings.xml index a0a534be86..e9871f3baf 100644 --- a/libs/pandares/src/main/res/values-th/strings.xml +++ b/libs/pandares/src/main/res/values-th/strings.xml @@ -432,6 +432,7 @@ ลบแล้ว + อัพเดตเกรดแล้ว กำลังโหลดเนื้อหา Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ คุณสามารถเปิดรายละเอียดผลงานจัดส่ง (Submission details) ได้จากที่นี่ + เกรด: %s %s นาที %s นาที diff --git a/libs/pandares/src/main/res/values-vi/strings.xml b/libs/pandares/src/main/res/values-vi/strings.xml index db8ee483c2..bcf91a4a53 100644 --- a/libs/pandares/src/main/res/values-vi/strings.xml +++ b/libs/pandares/src/main/res/values-vi/strings.xml @@ -432,6 +432,7 @@ Đã xóa + Đã cập nhật lớp Đang Tải Nội Dung Canvas… UnknownDevice @@ -1119,6 +1120,7 @@ Bạn có thể mở chi tiết Bài Nộp từ đây + Lớp: %s %s Phút %s Phút diff --git a/libs/pandares/src/main/res/values-zh/strings.xml b/libs/pandares/src/main/res/values-zh/strings.xml index 815e304d01..9fd0ff9c21 100644 --- a/libs/pandares/src/main/res/values-zh/strings.xml +++ b/libs/pandares/src/main/res/values-zh/strings.xml @@ -426,6 +426,7 @@ 已删除 + 评分已更新 正在加载Canvas内容… UnknownDevice @@ -1106,6 +1107,7 @@ 您可以从此处打开提交详情 + 评分:%s %s 分钟 diff --git a/libs/pandares/src/main/res/values/colors.xml b/libs/pandares/src/main/res/values/colors.xml index 5843539a2c..0adee136e1 100644 --- a/libs/pandares/src/main/res/values/colors.xml +++ b/libs/pandares/src/main/res/values/colors.xml @@ -20,44 +20,44 @@ #556572 #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 #F5F5F5 #FFFFFF - #008EE2 + #0374B5 #F5F5F5 #FFFFFF #FFFFFF #C7CDD1 - #00AC18 + #0B874B #FC5E13 #BF32A4 #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 - #008EE2 + #0374B5 #F5F5F5 #FFFFFF #C7CDD1 - #00AC18 + #0B874B #FC5E13 - #EE0612 - #008EE2 + #E0061F + #0374B5 #FC5E13 #2D3B45 #394B58 #F5F5F5 - #00AC18 + #0B874B #BF32A4 - #EE0612 + #E0061F #556572 #2D3B45 - #008EE2 + #0374B5 #F5F5F5 #FFFFFF - #00AC18 + #0B874B #FC5E13 #C7CDD1 #FFFFFF diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index c169672ff2..01f7f5f4cd 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -431,6 +431,7 @@ Deleted + Grade updated Loading Canvas Content… @@ -1117,6 +1118,7 @@ %s %s %s %s, %s You can open Submission details from here + Grade: %s %s Minute @@ -1398,4 +1400,5 @@ Email Version There was a problem reloading this assignment. Please check your connection and try again. + Instructure logo diff --git a/libs/pandautils/flank.yml b/libs/pandautils/flank.yml index 48bcce337e..60bd72123a 100644 --- a/libs/pandautils/flank.yml +++ b/libs/pandautils/flank.yml @@ -10,8 +10,8 @@ gcloud: test-targets: - notAnnotation com.instructure.canvas.espresso.E2E, com.instructure.canvas.espresso.Stub, com.instructure.canvas.espresso.FlakyE2E, com.instructure.canvas.espresso.KnownBug device: - - model: Nexus6P - version: 26 + - model: Pixel2.arm + version: 29 locale: en_US orientation: portrait diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json new file mode 100644 index 0000000000..de38825764 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/7.json @@ -0,0 +1,504 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "0d3dd1cb3dde3ba7d7a93ccddf7ccbf9", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0d3dd1cb3dde3ba7d7a93ccddf7ccbf9')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json new file mode 100644 index 0000000000..315060d789 --- /dev/null +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.appdatabase.AppDatabase/8.json @@ -0,0 +1,510 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "1e21f55714fd92fed820f2785cdb62d9", + "entities": [ + { + "tableName": "AttachmentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contentType` TEXT, `filename` TEXT, `displayName` TEXT, `url` TEXT, `thumbnailUrl` TEXT, `previewUrl` TEXT, `createdAt` INTEGER, `size` INTEGER NOT NULL, `workerId` TEXT, `submissionCommentId` INTEGER, `submissionId` INTEGER, `attempt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "previewUrl", + "columnName": "previewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "submissionCommentId", + "columnName": "submissionCommentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attempt", + "columnName": "attempt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AuthorEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `displayName` TEXT, `avatarImageUrl` TEXT, `htmlUrl` TEXT, `pronouns` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarImageUrl", + "columnName": "avatarImageUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "htmlUrl", + "columnName": "htmlUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EnvironmentFeatureFlags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `featureFlags` TEXT NOT NULL, PRIMARY KEY(`userId`))", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "featureFlags", + "columnName": "featureFlags", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "FileUploadInputEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `courseId` INTEGER, `assignmentId` INTEGER, `quizId` INTEGER, `quizQuestionId` INTEGER, `position` INTEGER, `parentFolderId` INTEGER, `action` TEXT NOT NULL, `userId` INTEGER, `attachments` TEXT NOT NULL, `submissionId` INTEGER, `filePaths` TEXT NOT NULL, `attemptId` INTEGER, `notificationId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizId", + "columnName": "quizId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quizQuestionId", + "columnName": "quizQuestionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "filePaths", + "columnName": "filePaths", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MediaCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mediaId` TEXT NOT NULL, `displayName` TEXT, `url` TEXT, `mediaType` TEXT, `contentType` TEXT, PRIMARY KEY(`mediaId`))", + "fields": [ + { + "fieldPath": "mediaId", + "columnName": "mediaId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "mediaId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `authorId` INTEGER NOT NULL, `authorName` TEXT, `authorPronouns` TEXT, `comment` TEXT, `createdAt` INTEGER, `mediaCommentId` TEXT, `attemptId` INTEGER, `submissionId` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPronouns", + "columnName": "authorPronouns", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mediaCommentId", + "columnName": "mediaCommentId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "submissionId", + "columnName": "submissionId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "PendingSubmissionCommentEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pageId` TEXT NOT NULL, `comment` TEXT, `date` INTEGER NOT NULL, `status` TEXT NOT NULL, `workerId` TEXT, `filePath` TEXT, `attemptId` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pageId", + "columnName": "pageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "DashboardFileUploadEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workerId` TEXT NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT, `subtitle` TEXT, `courseId` INTEGER, `assignmentId` INTEGER, `attemptId` INTEGER, `folderId` INTEGER, PRIMARY KEY(`workerId`))", + "fields": [ + { + "fieldPath": "workerId", + "columnName": "workerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "assignmentId", + "columnName": "assignmentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptId", + "columnName": "attemptId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workerId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e21f55714fd92fed820f2785cdb62d9')" + ] + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt index 3f91092fce..652c98bb0c 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/AttachmentDaoTest.kt @@ -53,7 +53,8 @@ class AttachmentDaoTest { @Test fun insertAndFindingByParentId() = runTest { - val attachmentEntity = AttachmentEntity(id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123 ) @@ -67,7 +68,8 @@ class AttachmentDaoTest { @Test fun dontReturnAnyItemIfEntitiesAreDeleted() = runTest { - val attachmentEntity = AttachmentEntity(id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123 ) @@ -80,4 +82,17 @@ class AttachmentDaoTest { Assert.assertEquals(0, result!!.size) } + @Test + fun testFindBySubmissionId() = runTest { + val attachmentEntity = AttachmentEntity( + id = 1, contentType = "image/jpg", filename = "image.jpg", displayName = "File", url = "file.com", + createdAt = Date(), size = 10000, workerId = "123", submissionCommentId = 123, submissionId = 1 + ) + val attachmentEntity2 = attachmentEntity.copy(id = 2, workerId = "124", filename = "image2.jpg", submissionId = 2) + attachmentDao.insertAll(listOf(attachmentEntity, attachmentEntity2)) + + val result = attachmentDao.findBySubmissionId(1) + + Assert.assertEquals(listOf(attachmentEntity), result) + } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt new file mode 100644 index 0000000000..efe955856a --- /dev/null +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDaoTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.instructure.pandautils.room.appdatabase.AppDatabase +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class EnvironmentFeatureFlagsDaoTest { + + private lateinit var db: AppDatabase + private lateinit var environmentFeatureFlagsDao: EnvironmentFeatureFlagsDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() + environmentFeatureFlagsDao = db.environmentFeatureFlagsDao() + } + + @After + fun tearDoown() { + db.close() + } + + @Test + fun testInsertReplace() = runTest { + val featureFlags = EnvironmentFeatureFlags( + userId = 1, + mapOf("feature_flag" to true) + ) + val updated = featureFlags.copy( + featureFlags = mapOf("feature_flag" to false) + ) + + environmentFeatureFlagsDao.insert(featureFlags) + + environmentFeatureFlagsDao.insert(updated) + + val result = environmentFeatureFlagsDao.findByUserId(1L) + + assertEquals(updated, result) + } + + @Test + fun testFindByUserId() = runTest { + val featureFlags = EnvironmentFeatureFlags( + userId = 1, + mapOf("feature_flag" to true) + ) + val featureFlags2 = EnvironmentFeatureFlags( + userId = 2, + mapOf("feature_flag" to false) + ) + + environmentFeatureFlagsDao.insert(featureFlags) + environmentFeatureFlagsDao.insert(featureFlags2) + + val result = environmentFeatureFlagsDao.findByUserId(1L) + + assertEquals(featureFlags, result) + } +} \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt index 3f143ee8c9..9b75a0be9d 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/appdatabase/daos/SubmissionCommentDaoTest.kt @@ -95,4 +95,26 @@ class SubmissionCommentDaoTest { Assert.assertEquals("Obi-Wan", result.author!!.displayName) Assert.assertEquals("Order 66", result.mediaComment!!.displayName) } + + @Test + fun testFindBySubmissionId() = runTest { + val submissionComment = SubmissionCommentEntity( + id = 1, + comment = "These are the droids you are looking for", + authorId = 1, + mediaCommentId = "66", + submissionId = 1 + ) + val submissionComment2 = SubmissionCommentEntity( + id = 2, + comment = "These are not the droids you are looking for", + submissionId = 2 + ) + submissionCommentDao.insertAll(listOf(submissionComment, submissionComment2)) + + val result = submissionCommentDao.findBySubmissionId(1) + + Assert.assertEquals(1, result.size) + Assert.assertEquals(submissionComment, result.first().submissionComment) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt index 311a14c31e..3d7f4675d5 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/DatabaseModule.kt @@ -57,4 +57,10 @@ class DatabaseModule { fun provideDashboardFileUploadDao(appDatabase: AppDatabase): DashboardFileUploadDao { return appDatabase.dashboardFileUploadDao() } + + @Provides + @Singleton + fun provideEnvironmentFeatureFlagsDao(appDatabase: AppDatabase): EnvironmentFeatureFlagsDao { + return appDatabase.environmentFeatureFlagsDao() + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt index 5836bc39de..7199dc3a53 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt @@ -16,8 +16,10 @@ */ package com.instructure.pandautils.di +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao import com.instructure.pandautils.utils.FeatureFlagProvider import dagger.Module import dagger.Provides @@ -29,7 +31,7 @@ import dagger.hilt.components.SingletonComponent class FeatureFlagModule { @Provides - fun provideFeatureFlagProvider(userManager: UserManager, apiPrefs: ApiPrefs): FeatureFlagProvider { - return FeatureFlagProvider(userManager, apiPrefs) + fun provideFeatureFlagProvider(userManager: UserManager, apiPrefs: ApiPrefs, featuresApi: FeaturesAPI.FeaturesInterface, environmentFeatureFlagsDao: EnvironmentFeatureFlagsDao): FeatureFlagProvider { + return FeatureFlagProvider(userManager, apiPrefs, featuresApi, environmentFeatureFlagsDao) } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt index 04cf4f703d..7745073010 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/dialogs/RatingDialog.kt @@ -113,13 +113,11 @@ class RatingDialog : DialogFragment() { val starClickListener = View.OnClickListener { v -> stars.forEach { - it.setImageResource(R.drawable.ic_star) - it.setColorFilter(requireContext().getColor(R.color.backgroundMedium)) + it.setImageResource(R.drawable.ic_rating_star_outline) } val selectionIndex = stars.indexOf(v) stars.take(selectionIndex + 1).forEach { - it.setImageResource(R.drawable.ic_star) - it.setColorFilter(requireContext().getColor(R.color.backgroundDark)) + it.setImageResource(R.drawable.ic_rating_star) } val isFiveStars = selectionIndex >= 4 comments.setVisible(!isFiveStars) @@ -132,7 +130,6 @@ class RatingDialog : DialogFragment() { } stars.forEach { - it.setImageResource(R.drawable.ic_star) it.setOnClickListener(starClickListener) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt index cabebc8425..e942a573f2 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewData.kt @@ -31,7 +31,8 @@ data class GradeRowViewData( val courseColor: ThemedColor, val courseImageUrl: String, val score: Double?, - val gradeText: String) + val gradeText: String, + val hideProgress: Boolean = false) sealed class GradesAction { data class OpenCourseGrades(val course: Course) : GradesAction() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt index 5f0cdd6a9d..686a6def4b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/grades/GradesViewModel.kt @@ -132,6 +132,8 @@ class GradesViewModel @Inject constructor( .map { val enrollment = it.enrollments?.first() val grades = it.getCourseGrade(false) + val restrictQuantitativeData = it.settings?.restrictQuantitativeData ?: false + val notGraded = (enrollment?.currentGradingPeriodId ?: 0L) != 0L GradeRowItemViewModel(resources, GradeRowViewData( it.id, @@ -139,7 +141,14 @@ class GradesViewModel @Inject constructor( colorKeeper.getOrGenerateColor(it), it.imageUrl ?: "", if (it.hideFinalGrades) 0.0 else grades?.currentScore, - createGradeText(grades?.currentScore, grades?.currentGrade, it.hideFinalGrades, enrollment?.currentGradingPeriodId ?: 0L != 0L)) + createGradeText( + grades?.currentScore, + grades?.currentGrade, + it.hideFinalGrades, + notGraded = notGraded, + restrictQuantitativeData = restrictQuantitativeData + ), + hideProgress = restrictQuantitativeData || notGraded || it.hideFinalGrades) ) { gradeRowClicked(it) } } } @@ -155,14 +164,14 @@ class GradesViewModel @Inject constructor( return GradesViewData(items) } - private fun createGradeText(score: Double?, grade: String?, hideFinalGrades: Boolean, notGraded: Boolean = true): String { + private fun createGradeText(score: Double?, grade: String?, hideFinalGrades: Boolean, notGraded: Boolean = true, restrictQuantitativeData: Boolean = true): String { return when { hideFinalGrades -> "--" !grade.isNullOrEmpty() -> grade else -> { val currentScoreRounded = score?.roundToInt() when { - currentScoreRounded != null -> "$currentScoreRounded%" + currentScoreRounded != null && !restrictQuantitativeData -> "$currentScoreRounded%" notGraded -> resources.getString(R.string.notGraded) else -> "--" } @@ -236,13 +245,15 @@ class GradesViewModel @Inject constructor( } private fun createGradeRowFromEnrollment(course: Course, enrollment: Enrollment?): GradeRowItemViewModel { + val restrictQuantitativeData = course.settings?.restrictQuantitativeData ?: false val gradeRowViewData = GradeRowViewData( course.id, course.name, colorKeeper.getOrGenerateColor(course), course.imageUrl ?: "", enrollment?.grades?.currentScore, - createGradeText(enrollment?.grades?.currentScore, enrollment?.grades?.currentGrade, course.hideFinalGrades)) + createGradeText(enrollment?.grades?.currentScore, enrollment?.grades?.currentGrade, course.hideFinalGrades, restrictQuantitativeData), + restrictQuantitativeData || course.hideFinalGrades) return GradeRowItemViewModel(resources, gradeRowViewData) { gradeRowClicked(course) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt index c1964fa976..c819bbee85 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModel.kt @@ -188,7 +188,7 @@ class ScheduleViewModel @Inject constructor( simpleDateFormat.format(it) ) }, - points = getPointsText(assignment.pointsPossible), + points = getPointsText(assignment.pointsPossible, assignment.courseId), type = if (assignment.discussionTopicHeader != null) PlannerItemType.DISCUSSION else PlannerItemType.ASSIGNMENT, courseName = coursesMap[assignment.courseId]?.name, courseColor = color, @@ -342,7 +342,7 @@ class ScheduleViewModel @Inject constructor( SchedulePlannerItemData( plannerItem.plannable.title, getTypeForPlannerItem(plannerItem), - getPointsText(plannerItem.plannable.pointsPossible), + getPointsText(plannerItem.plannable.pointsPossible, plannerItem.courseId ?: 0), getDueText(plannerItem), isPlannableOpenable(plannerItem), createContentDescription(plannerItem), @@ -513,8 +513,12 @@ class ScheduleViewModel @Inject constructor( } } - private fun getPointsText(points: Double?): String? { + private fun getPointsText(points: Double?, courseId: Long): String? { if (points == null) return null + + val course = coursesMap[courseId] + if (course?.settings?.restrictQuantitativeData == true) return null + val numberFormatter = DecimalFormat("##.##") return resources.getQuantityString(R.plurals.schedule_points, points.toInt(), numberFormatter.format(points)) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt new file mode 100644 index 0000000000..954d39fdd5 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/file/download/FileDownloadWorker.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.features.file.download + +import android.app.DownloadManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Environment +import androidx.core.app.NotificationCompat +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkerParameters +import com.instructure.canvasapi2.apis.DownloadState +import com.instructure.canvasapi2.apis.FileDownloadAPI +import com.instructure.canvasapi2.apis.saveFile +import com.instructure.pandautils.R +import com.instructure.pandautils.features.file.upload.worker.FileUploadWorker +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.io.File +import kotlin.random.Random + +@HiltWorker +class FileDownloadWorker @AssistedInject constructor( + @Assisted private val context: Context, + @Assisted workerParameters: WorkerParameters, + private val fileDownloadApi: FileDownloadAPI +) : CoroutineWorker(context, workerParameters) { + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + private val fileName = inputData.getString(INPUT_FILE_NAME) ?: "" + private val fileUrl = inputData.getString(INPUT_FILE_URL) ?: "" + private val notificationId = Random.nextInt() + + private var foregroundInfo: ForegroundInfo = createForegroundInfo(notificationId, fileName, 0) + + override suspend fun doWork(): Result { + registerNotificationChannel(context) + + val downloadFileName = createDownloadFileName(fileName) + + val downloadedFile = + File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), downloadFileName) + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + setForeground(foregroundInfo) + } + var result = Result.retry() + + fileDownloadApi.downloadFile(fileUrl).saveFile(downloadedFile) + .collect { downloadState -> + when (downloadState) { + is DownloadState.InProgress -> { + foregroundInfo = createForegroundInfo(notificationId, fileName, downloadState.progress) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + setForeground(foregroundInfo) + } else { + updateForegroundNotification() + } + } + + is DownloadState.Failure -> { + result = Result.failure() + updateNotificationFailed(notificationId, fileName) + } + + is DownloadState.Success -> { + result = Result.success() + updateNotificationComplete(notificationId, fileName) + } + } + } + + return result + } + + private fun createDownloadFileName(fileName: String): String { + var downloadedFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + fileName + ) + val fileNameWithoutExtension = downloadedFile.nameWithoutExtension + val fileExtension = downloadedFile.extension + var counter = 1 + while (downloadedFile.exists()) { + downloadedFile = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + "$fileNameWithoutExtension($counter).$fileExtension" + ) + counter++ + } + + return downloadedFile.name + } + + private fun registerNotificationChannel(context: Context) { + if (notificationManager.notificationChannels.any { it.id == CHANNEL_ID }) return + + val name = context.getString(R.string.notificationChannelNameFileUploadsName) + val description = context.getString(R.string.notificationChannelNameFileUploadsDescription) + val importance = NotificationManager.IMPORTANCE_HIGH + val channel = NotificationChannel(CHANNEL_ID, name, importance) + channel.description = description + + notificationManager.createNotificationChannel(channel) + } + + private fun createForegroundInfo(notificationId: Int, fileName: String, progress: Int): ForegroundInfo { + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.downloadingFile)) + .setContentText(fileName) + .setOnlyAlertOnce(true) + .setProgress(100, progress, false) + .setOngoing(progress != 100) + .build() + + return ForegroundInfo(notificationId, notification) + } + + private fun updateNotificationComplete(notificationId: Int, fileName: String) { + val viewDownloadIntent = Intent(DownloadManager.ACTION_VIEW_DOWNLOADS) + val pendingIntent = PendingIntent.getActivity(context, 0, viewDownloadIntent, PendingIntent.FLAG_IMMUTABLE) + + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.downloadSuccessful)) + .setContentText(fileName) + .setContentIntent(pendingIntent) + .build() + notificationManager.notify(notificationId + 1, notification) + } + + private fun updateNotificationFailed(notificationId: Int, fileName: String) { + val notification = NotificationCompat.Builder(applicationContext, FileUploadWorker.CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_canvas_logo) + .setContentTitle(context.getString(R.string.downloadFailed)) + .setContentText(fileName) + .build() + notificationManager.notify(notificationId + 1, notification) + } + + override suspend fun getForegroundInfo(): ForegroundInfo { + return foregroundInfo + } + + private fun updateForegroundNotification() { + notificationManager.notify(notificationId, foregroundInfo.notification) + } + + companion object { + const val INPUT_FILE_NAME = "fileName" + const val INPUT_FILE_URL = "fileUrl" + + const val CHANNEL_ID = "uploadChannel" + + fun createOneTimeWorkRequest(fileName: String, fileUrl: String): OneTimeWorkRequest { + val inputData = androidx.work.Data.Builder() + .putString(INPUT_FILE_NAME, fileName) + .putString(INPUT_FILE_URL, fileUrl) + .build() + + return OneTimeWorkRequest.Builder(FileDownloadWorker::class.java) + .setInputData(inputData) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + } + } +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt index 243e856f65..152bdae77c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabase.kt @@ -3,9 +3,9 @@ package com.instructure.pandautils.room.appdatabase import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.instructure.pandautils.room.common.Converters import com.instructure.pandautils.room.appdatabase.daos.* import com.instructure.pandautils.room.appdatabase.entities.* +import com.instructure.pandautils.room.common.Converters import com.instructure.pandautils.room.common.daos.AttachmentDao import com.instructure.pandautils.room.common.daos.AuthorDao import com.instructure.pandautils.room.common.daos.MediaCommentDao @@ -19,12 +19,13 @@ import com.instructure.pandautils.room.common.entities.SubmissionCommentEntity entities = [ AttachmentEntity::class, AuthorEntity::class, + EnvironmentFeatureFlags::class, FileUploadInputEntity::class, MediaCommentEntity::class, SubmissionCommentEntity::class, PendingSubmissionCommentEntity::class, DashboardFileUploadEntity::class - ], version = 6 + ], version = 8 ) @TypeConverters(Converters::class) abstract class AppDatabase : RoomDatabase() { @@ -42,4 +43,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun pendingSubmissionCommentDao(): PendingSubmissionCommentDao abstract fun dashboardFileUploadDao(): DashboardFileUploadDao + + abstract fun environmentFeatureFlagsDao(): EnvironmentFeatureFlagsDao } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt index fe79a15f37..f7cb1e1095 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/AppDatabaseMigrations.kt @@ -45,5 +45,13 @@ val appDatabaseMigrations = arrayOf( createMigration(5, 6) { database -> database.execSQL("ALTER TABLE AttachmentEntity ADD COLUMN submissionId INTEGER") database.execSQL("ALTER TABLE SubmissionCommentEntity ADD COLUMN submissionId INTEGER") + }, + + createMigration(6, 7) { database -> + database.execSQL("ALTER TABLE AttachmentEntity ADD COLUMN attempt INTEGER") + }, + + createMigration(7, 8) { database -> + database.execSQL("CREATE TABLE IF NOT EXISTS EnvironmentFeatureFlags (userId INTEGER NOT NULL, featureFlags TEXT NOT NULL, PRIMARY KEY(userId))") } ) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt new file mode 100644 index 0000000000..3e1739202f --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/daos/EnvironmentFeatureFlagsDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.daos + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags + +@Dao +interface EnvironmentFeatureFlagsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(featureFlags: EnvironmentFeatureFlags) + + @Query("SELECT * FROM EnvironmentFeatureFlags WHERE userId = :userId") + suspend fun findByUserId(userId: Long): EnvironmentFeatureFlags? +} \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt new file mode 100644 index 0000000000..c6e91cb2f4 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/appdatabase/entities/EnvironmentFeatureFlags.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package com.instructure.pandautils.room.appdatabase.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class EnvironmentFeatureFlags( + @PrimaryKey + val userId: Long, + val featureFlags: Map +) \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt index 061e9ddb56..9afca310b8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/Converters.kt @@ -1,6 +1,8 @@ package com.instructure.pandautils.room.common import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import java.util.* class Converters { @@ -33,4 +35,14 @@ class Converters { fun longToDate(timestamp: Long?): Date? { return timestamp?.let { Date(it) } } + + @TypeConverter + fun stringToStringBooleanMap(value: String): Map { + return Gson().fromJson(value, object : TypeToken>() {}.type) + } + + @TypeConverter + fun stringBooleanMapToString(value: Map?): String { + return if(value == null) "" else Gson().toJson(value) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt index 8d2942bf75..54b4c5d147 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/AttachmentDao.kt @@ -6,10 +6,10 @@ import com.instructure.pandautils.room.common.entities.AttachmentEntity @Dao interface AttachmentDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(attachment: AttachmentEntity) - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(attachments: List) @Delete @@ -23,4 +23,7 @@ interface AttachmentDao { @Query("SELECT * FROM AttachmentEntity WHERE workerId=:parentId") suspend fun findByParentId(parentId: String): List? + + @Query("SELECT * FROM AttachmentEntity WHERE submissionId=:submissionId") + suspend fun findBySubmissionId(submissionId: Long): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt index 075ce58bb8..36f42f5a32 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/daos/SubmissionCommentDao.kt @@ -7,9 +7,12 @@ import com.instructure.pandautils.room.common.model.SubmissionCommentWithAttachm @Dao interface SubmissionCommentDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(submissionComment: SubmissionCommentEntity): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(submissionComments: List) + @Delete suspend fun delete(submissionComment: SubmissionCommentEntity) @@ -19,4 +22,7 @@ interface SubmissionCommentDao { @Transaction @Query("SELECT * FROM SubmissionCommentEntity WHERE id=:id") suspend fun findById(id: Long): SubmissionCommentWithAttachments? + + @Query("SELECT * FROM SubmissionCommentEntity WHERE submissionId=:submissionId") + suspend fun findBySubmissionId(submissionId: Long): List } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt index e3f7333dfa..3c6c72e421 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/AttachmentEntity.kt @@ -3,7 +3,7 @@ package com.instructure.pandautils.room.common.entities import androidx.room.Entity import androidx.room.PrimaryKey import com.instructure.canvasapi2.models.Attachment -import java.util.* +import java.util.Date @Entity data class AttachmentEntity( @@ -20,33 +20,40 @@ data class AttachmentEntity( val workerId: String? = null, //Used for Submission comments val submissionCommentId: Long? = null, - val submissionId: Long? = null + val submissionId: Long? = null, + val attempt: Long? = null ) { - constructor(attachment: Attachment, workerId: String? = null, submissionCommentId: Long? = null) : this( - attachment.id, - attachment.contentType, - attachment.filename, - attachment.displayName, - attachment.url, - attachment.thumbnailUrl, - attachment.previewUrl, - attachment.createdAt, - attachment.size, - workerId, - submissionCommentId + constructor( + attachment: Attachment, + workerId: String? = null, + submissionCommentId: Long? = null, + submissionId: Long? = null, + attempt: Long? = null + ) : this( + id = attachment.id, + contentType = attachment.contentType, + filename = attachment.filename, + displayName = attachment.displayName, + url = attachment.url, + thumbnailUrl = attachment.thumbnailUrl, + previewUrl = attachment.previewUrl, + createdAt = attachment.createdAt, + size = attachment.size, + workerId = workerId, + submissionCommentId = submissionCommentId, + submissionId = submissionId, + attempt = attempt ) - fun toApiModel(): Attachment { - return Attachment( - id, - contentType, - filename, - displayName, - url, - thumbnailUrl, - previewUrl, - createdAt, - size - ) - } -} \ No newline at end of file + fun toApiModel() = Attachment( + id = id, + contentType = contentType, + filename = filename, + displayName = displayName, + url = url, + thumbnailUrl = thumbnailUrl, + previewUrl = previewUrl, + createdAt = createdAt, + size = size + ) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt index 6e477b4e19..7c17c84e09 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/common/entities/SubmissionCommentEntity.kt @@ -3,7 +3,7 @@ package com.instructure.pandautils.room.common.entities import androidx.room.Entity import androidx.room.PrimaryKey import com.instructure.canvasapi2.models.SubmissionComment -import java.util.* +import java.util.Date @Entity data class SubmissionCommentEntity( @@ -17,14 +17,15 @@ data class SubmissionCommentEntity( val attemptId: Long? = null, val submissionId: Long? = null ) { - constructor(submissionComment: SubmissionComment): this( - submissionComment.id, - submissionComment.authorId, - submissionComment.authorName, - submissionComment.authorPronouns, - submissionComment.comment, - submissionComment.createdAt, - submissionComment.mediaComment?.mediaId, - submissionComment.attempt + constructor(submissionComment: SubmissionComment, submissionId: Long? = null) : this( + id = submissionComment.id, + authorId = submissionComment.authorId, + authorName = submissionComment.authorName, + authorPronouns = submissionComment.authorPronouns, + comment = submissionComment.comment, + createdAt = submissionComment.createdAt, + mediaCommentId = submissionComment.mediaComment?.mediaId, + attemptId = submissionComment.attempt, + submissionId = submissionId ) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt index 21e93b56fa..a87dda717e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/FeatureFlagProvider.kt @@ -16,13 +16,19 @@ */ package com.instructure.pandautils.utils +import com.instructure.canvasapi2.apis.FeaturesAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.BuildConfig +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags class FeatureFlagProvider( private val userManager: UserManager, - private val apiPrefs: ApiPrefs + private val apiPrefs: ApiPrefs, + private val featuresApi: FeaturesAPI.FeaturesInterface, + private val environmentFeatureFlags: EnvironmentFeatureFlagsDao ) { suspend fun getCanvasForElementaryFlag(): Boolean { @@ -39,4 +45,16 @@ class FeatureFlagProvider( fun getDiscussionRedesignFeatureFlag(): Boolean { return BuildConfig.IS_DEBUG } + + suspend fun fetchEnvironmentFeatureFlags() { + val restParams = RestParams(isForceReadFromNetwork = false) + val featureFlags = featuresApi.getEnvironmentFeatureFlags(restParams).dataOrNull ?: return + apiPrefs.user?.id?.let { + environmentFeatureFlags.insert(EnvironmentFeatureFlags(it, featureFlags)) + } + } + + suspend fun checkEnvironmentFeatureFlag(featureFlag: String): Boolean { + return apiPrefs.user?.id?.let { environmentFeatureFlags.findByUserId(it)?.featureFlags?.get(featureFlag) == true } ?: false + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/res/color/calendar_color_selector.xml b/libs/pandautils/src/main/res/color/calendar_color_selector.xml new file mode 100644 index 0000000000..1ba3935ed9 --- /dev/null +++ b/libs/pandautils/src/main/res/color/calendar_color_selector.xml @@ -0,0 +1,20 @@ + + + + + \ No newline at end of file diff --git a/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml b/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml index 6a589cbc65..5b98f2c63b 100644 --- a/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml +++ b/libs/pandautils/src/main/res/layout-sw720dp/item_grade_row.xml @@ -101,6 +101,7 @@ android:layout_marginTop="6dp" android:layout_marginStart="12dp" android:layout_marginEnd="8dp" + app:visible="@{!itemViewModel.data.hideProgress}" app:layout_constraintStart_toEndOf="@id/courseImage" app:layout_constraintTop_toBottomOf="@id/gradesCourseNameText" app:layout_constraintEnd_toStartOf="@id/chevronIcon"> diff --git a/libs/pandautils/src/main/res/layout/dialog_rating.xml b/libs/pandautils/src/main/res/layout/dialog_rating.xml index 007ed070e7..ab56eb6e20 100644 --- a/libs/pandautils/src/main/res/layout/dialog_rating.xml +++ b/libs/pandautils/src/main/res/layout/dialog_rating.xml @@ -39,8 +39,8 @@ android:layout_marginLeft="4dp" android:layout_marginRight="4dp" android:contentDescription="@string/star1" - android:src="@drawable/ic_star" - app:tint="@color/backgroundMedium" /> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark" /> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> + android:src="@drawable/ic_rating_star_outline" + app:tint="@color/backgroundDark"/> diff --git a/libs/pandautils/src/main/res/layout/fragment_about.xml b/libs/pandautils/src/main/res/layout/fragment_about.xml index 5d26338930..12b2463385 100644 --- a/libs/pandautils/src/main/res/layout/fragment_about.xml +++ b/libs/pandautils/src/main/res/layout/fragment_about.xml @@ -139,7 +139,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" - android:importantForAccessibility="no" + android:contentDescription="@string/instructure_logo" android:src="@drawable/ic_instructure_logo" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/libs/pandautils/src/main/res/layout/item_grade_row.xml b/libs/pandautils/src/main/res/layout/item_grade_row.xml index f1e3bc69bb..4454b86170 100644 --- a/libs/pandautils/src/main/res/layout/item_grade_row.xml +++ b/libs/pandautils/src/main/res/layout/item_grade_row.xml @@ -73,7 +73,8 @@ android:src="@drawable/ic_chevron_right" android:layout_marginEnd="8dp" android:tint="@color/textDark" - app:layout_constraintTop_toBottomOf="@id/gradesCourseNameText" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> diff --git a/libs/pandautils/src/main/res/values/font_styles.xml b/libs/pandautils/src/main/res/values/font_styles.xml index 1e19ec8b65..51fa9e391c 100644 --- a/libs/pandautils/src/main/res/values/font_styles.xml +++ b/libs/pandautils/src/main/res/values/font_styles.xml @@ -42,4 +42,8 @@ italic + + \ No newline at end of file diff --git a/libs/pandautils/src/main/res/values/styles.xml b/libs/pandautils/src/main/res/values/styles.xml index 87edd80e33..ea6efca1aa 100644 --- a/libs/pandautils/src/main/res/values/styles.xml +++ b/libs/pandautils/src/main/res/values/styles.xml @@ -219,4 +219,8 @@ @color/white + + diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt index a89b984626..28722f89c5 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/grades/GradesViewModelTest.kt @@ -23,8 +23,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.instructure.canvasapi2.managers.CourseManager import com.instructure.canvasapi2.managers.EnrollmentManager -import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.CourseSettings import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.GradingPeriod import com.instructure.canvasapi2.utils.DataResult @@ -32,10 +32,8 @@ import com.instructure.pandautils.R import com.instructure.pandautils.features.elementary.grades.itemviewmodels.GradeRowItemViewModel import com.instructure.pandautils.features.elementary.grades.itemviewmodels.GradingPeriodSelectorItemViewModel import com.instructure.pandautils.mvvm.ViewState -import com.instructure.pandautils.utils.ColorApiHelper import com.instructure.pandautils.utils.ColorKeeper import com.instructure.pandautils.utils.ThemedColor -import com.instructure.pandautils.utils.textAndIconColor import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -142,7 +140,7 @@ class GradesViewModelTest { val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "A") val expectedGradeRow2 = GradeRowViewData(2, "Course with Score", ThemedColor(0), "www.1.com", 75.6, "76%") val expectedGradeRow3 = GradeRowViewData(3, "Course without scores", ThemedColor(0), "www.1.com", null, "--") - val expectedGradeRow4 = GradeRowViewData(4, "Hide Final Grades", ThemedColor(0), "www.1.com", 0.0, "--") + val expectedGradeRow4 = GradeRowViewData(4, "Hide Final Grades", ThemedColor(0), "www.1.com", 0.0, "--", hideProgress = true) assertEquals(expectedGradeRow1, gradeRows[0].data) assertEquals(expectedGradeRow2, gradeRows[1].data) @@ -347,6 +345,57 @@ class GradesViewModelTest { assertEquals(GradesAction.OpenGradingPeriodsDialog(expectedGradingPeriods, 0), viewModel.events.value!!.getContentIfNotHandled()) } + @Test + fun `Hide progress when quantitative data is restricted`() { + // Given + val course = createCourseWithGrades(1, "Course with Grade", "", "www.1.com", 90.0, "A") + .copy(settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesWithGradesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + // When + viewModel = createViewModel() + viewModel.state.observe(lifecycleOwner, {}) + + // Then + assertTrue(viewModel.state.value is ViewState.Success) + assertEquals(1, viewModel.data.value!!.items.size) + + val gradeRows = viewModel.data.value!!.items.map { it as GradeRowItemViewModel } + + val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "A", hideProgress = true) + + assertEquals(expectedGradeRow1, gradeRows[0].data) + } + + @Test + fun `Do not show score when quantitative data is restricted and there is no grade`() { + // Given + val course = createCourseWithGrades(1, "Course with Grade", "", "www.1.com", 90.0, null) + .copy(settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesWithGradesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + // When + viewModel = createViewModel() + viewModel.state.observe(lifecycleOwner, {}) + + // Then + assertTrue(viewModel.state.value is ViewState.Success) + assertEquals(1, viewModel.data.value!!.items.size) + + val gradeRows = viewModel.data.value!!.items.map { it as GradeRowItemViewModel } + + val expectedGradeRow1 = GradeRowViewData(1, "Course with Grade", ThemedColor(0), "www.1.com", 90.0, "--", hideProgress = true) + + assertEquals(expectedGradeRow1, gradeRows[0].data) + } + + private fun createViewModel() = GradesViewModel(courseManager, resources, enrollmentManager, colorKeeper) private fun createCourseWithGrades( diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt index 72b89e395e..6d97801bea 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/elementary/schedule/ScheduleViewModelTest.kt @@ -201,7 +201,8 @@ class ScheduleViewModelTest { 1, courseId = 1, createSubmission(id = 1, grade = null, late = false, excused = false), - name = "Assignment 1" + name = "Assignment 1", + pointsPossible = 20.0 ), createAssignment( 2, @@ -232,12 +233,55 @@ class ScheduleViewModelTest { val firstMissingItem = missingItemHeader.items[0] as ScheduleMissingItemViewModel assertEquals("Assignment 1", firstMissingItem.data.title) assertEquals("Course 1", firstMissingItem.data.courseName) + assertEquals("20 pts", firstMissingItem.data.points) val secondMissingItem = missingItemHeader.items[1] as ScheduleMissingItemViewModel assertEquals("Assignment 2", secondMissingItem.data.title) assertEquals("Course 2", secondMissingItem.data.courseName) } + @Test + fun `Missing item points are not displayed if quantitative data is restricted`() { + val courses = listOf(Course(id = 1, name = "Course 1", settings = CourseSettings(restrictQuantitativeData = true)),) + + every { courseManager.getCoursesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(courses) + } + + val missingItems = listOf( + createAssignment( + 1, + courseId = 1, + createSubmission(id = 1, grade = null, late = false, excused = false), + name = "Assignment 1", + pointsPossible = 20.0 + ) + ) + + every { userManager.getAllMissingSubmissionsAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(missingItems) + } + + viewModel = createViewModel() + viewModel.getDataForDate(Date().toApiString()) + viewModel.data.observe(lifecycleOwner, {}) + + val items = viewModel.data.value?.itemViewModels + + val todayHeader = items?.find { it.dayText == "Today" } + assert(todayHeader is ScheduleDayGroupItemViewModel) + assertEquals("Today", todayHeader?.dayText) + + val missingItemHeader = + todayHeader?.items?.find { it is ScheduleMissingItemsGroupItemViewModel } as ScheduleMissingItemsGroupItemViewModel + assertEquals(1, missingItemHeader.items.size) + + val firstMissingItem = missingItemHeader.items.first() as ScheduleMissingItemViewModel + assertEquals("Assignment 1", firstMissingItem.data.title) + assertEquals("Course 1", firstMissingItem.data.courseName) + assertEquals(null, firstMissingItem.data.points) + } + @Test fun `Missing items are open by default`() { val course = Course(id = 1) @@ -640,7 +684,54 @@ class ScheduleViewModelTest { assignmentId = 1, PlannableType.ASSIGNMENT, SubmissionState(), - Date() + Date(), + pointsPossible = 20.0 + ) + ) + + every { plannerManager.getPlannerItemsAsync(any(), any(), any()) } returns mockk { + coEvery { await() } returns DataResult.Success(plannerItems) + } + + viewModel = createViewModel() + viewModel.getDataForDate(Date().toApiString()) + viewModel.data.observe(lifecycleOwner, {}) + + val items = viewModel.data.value?.itemViewModels + + val todayHeader = items?.find { it.dayText == "Today" } + assert(todayHeader is ScheduleDayGroupItemViewModel) + assertEquals("Today", todayHeader?.dayText) + + val courseItemViewModel = todayHeader?.items?.get(0) as ScheduleCourseItemViewModel + + assertEquals(true, courseItemViewModel.data.openable) + + assertEquals(1, courseItemViewModel.data.plannerItems.size) + val plannerItemViewModel = courseItemViewModel.data.plannerItems[0] + + assertEquals("Plannable 1", plannerItemViewModel.data.title) + assertEquals(true, plannerItemViewModel.data.openable) + assertEquals(PlannerItemType.ASSIGNMENT, plannerItemViewModel.data.type) + assertEquals("20 pts", plannerItemViewModel.data.points) + } + + @Test + fun `Assignment points are not displayed with restricted quantitative data`() { + val course = Course(id = 1, settings = CourseSettings(restrictQuantitativeData = true)) + + every { courseManager.getCoursesAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(listOf(course)) + } + + val plannerItems = listOf( + createPlannerItem( + courseId = course.id, + assignmentId = 1, + PlannableType.ASSIGNMENT, + SubmissionState(), + Date(), + pointsPossible = 20.0 ) ) @@ -818,7 +909,8 @@ class ScheduleViewModelTest { date: Date, plannerOverride: PlannerOverride? = null, newActivity: Boolean = false, - todoDate: String? = null + todoDate: String? = null, + pointsPossible: Double? = null ): PlannerItem { val plannable = Plannable( id = assignmentId, @@ -826,7 +918,7 @@ class ScheduleViewModelTest { courseId, null, null, - null, + pointsPossible, date, assignmentId, todoDate @@ -866,14 +958,16 @@ class ScheduleViewModelTest { courseId: Long, submission: Submission? = null, discussionTopicHeader: DiscussionTopicHeader? = null, - name: String? = null + name: String? = null, + pointsPossible: Double? = null ): Assignment { return Assignment( id = id, submission = submission, discussionTopicHeader = discussionTopicHeader, courseId = courseId, - name = name + name = name, + pointsPossible = pointsPossible ?: 0.0 ) } @@ -912,6 +1006,7 @@ class ScheduleViewModelTest { every { resources.getString(R.string.schedule_todo_title) } returns "To Do" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 2, 2) } returns "2 Replies" every { resources.getQuantityString(R.plurals.schedule_tag_replies, 1, 1) } returns "1 Reply" + every { resources.getQuantityString(R.plurals.schedule_points, 20, "20") } returns "20 pts" } private fun createViewModel(): ScheduleViewModel { diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt index 85a52edc43..7fe768bd2d 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/utils/FeatureFlagProviderTest.kt @@ -16,11 +16,15 @@ */ package com.instructure.pandautils.utils +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.pandautils.room.appdatabase.daos.EnvironmentFeatureFlagsDao +import com.instructure.pandautils.room.appdatabase.entities.EnvironmentFeatureFlags import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -28,6 +32,7 @@ import junit.framework.Assert.assertFalse import junit.framework.Assert.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -36,8 +41,10 @@ class FeatureFlagProviderTest { private val userManager: UserManager = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val featuresApi: FeaturesAPI.FeaturesInterface = mockk(relaxed = true) + private val environmentFeatureFlags: EnvironmentFeatureFlagsDao = mockk(relaxed = true) - private val featureFlagProvider = FeatureFlagProvider(userManager, apiPrefs) + private val featureFlagProvider = FeatureFlagProvider(userManager, apiPrefs, featuresApi, environmentFeatureFlags) @Before fun setUp() { @@ -116,18 +123,41 @@ class FeatureFlagProviderTest { } @Test - fun `Return false if remote config flag and feature flag is enabled but dashboard override is false`() = runBlockingTest { - // Given - every { apiPrefs.elementaryDashboardEnabledOverride } returns false - every { userManager.getSelfAsync(any()) } returns mockk { - coEvery { await() } returns DataResult.Success(User(k5User = true)) + fun `Return false if remote config flag and feature flag is enabled but dashboard override is false`() = + runBlockingTest { + // Given + every { apiPrefs.elementaryDashboardEnabledOverride } returns false + every { userManager.getSelfAsync(any()) } returns mockk { + coEvery { await() } returns DataResult.Success(User(k5User = true)) + } + + + // When + val canvasForElementaryFlag = featureFlagProvider.getCanvasForElementaryFlag() + + // Then + assertFalse(canvasForElementaryFlag) } + @Test + fun `Save environment feature flags`() = runTest { + val featureFlags = mapOf("feature_flag" to true) + every { apiPrefs.user } returns User(id = 1L) + coEvery { featuresApi.getEnvironmentFeatureFlags(any()) } returns DataResult.Success(featureFlags) - // When - val canvasForElementaryFlag = featureFlagProvider.getCanvasForElementaryFlag() + featureFlagProvider.fetchEnvironmentFeatureFlags() - // Then - assertFalse(canvasForElementaryFlag) + coVerify(exactly = 1) { environmentFeatureFlags.insert(EnvironmentFeatureFlags(1L, featureFlags)) } + } + + @Test + fun `Check environment feature flag`() = runTest { + val featureFlags = mapOf("feature_flag" to true, + "feature_flag_2" to false) + + coEvery { environmentFeatureFlags.findByUserId(any()) } returns EnvironmentFeatureFlags(1L, featureFlags) + + assert(featureFlagProvider.checkEnvironmentFeatureFlag("feature_flag")) + assertFalse(featureFlagProvider.checkEnvironmentFeatureFlag("feature_flag_2")) } } \ No newline at end of file