core/templates/pages/exploration-player-page/layout-directives/exploration-footer.component.spec.ts

Summary

Maintainability
F
1 wk
Test Coverage
// Copyright 2021 The Oppia Authors. All Rights Reserved.
//
// 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.

/**
 * @fileoverview Unit tests for showing author/share footer
 * in exploration player.
 */

import {NO_ERRORS_SCHEMA} from '@angular/core';
import {
  async,
  ComponentFixture,
  fakeAsync,
  TestBed,
  tick,
} from '@angular/core/testing';
import {ExplorationFooterComponent} from './exploration-footer.component';
import {NgbModal, NgbModalRef, NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {TranslateService} from '@ngx-translate/core';
import {MockTranslateService} from 'components/forms/schema-based-editors/integration-tests/schema-based-editors.integration.spec';
import {MockTranslatePipe} from 'tests/unit-test-utils';
import {LimitToPipe} from 'filters/limit-to.pipe';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {ContextService} from 'services/context.service';
import {UrlService} from 'services/contextual/url.service';
import {WindowDimensionsService} from 'services/contextual/window-dimensions.service';
import {
  ExplorationSummaryBackendApiService,
  ExplorationSummaryDict,
} from 'domain/summary/exploration-summary-backend-api.service';
import {EventEmitter} from '@angular/core';
import {QuestionPlayerStateService} from 'components/question-directives/question-player/services/question-player-state.service';
import {LearnerExplorationSummaryBackendDict} from 'domain/summary/learner-exploration-summary.model';
import {LearnerViewInfoBackendApiService} from '../services/learner-view-info-backend-api.service';
import {LoggerService} from 'services/contextual/logger.service';
import {
  FetchExplorationBackendResponse,
  ReadOnlyExplorationBackendApiService,
} from 'domain/exploration/read-only-exploration-backend-api.service';
import {ExplorationEngineService} from '../services/exploration-engine.service';
import {StateObjectFactory} from 'domain/state/StateObjectFactory';
import {EditableExplorationBackendApiService} from 'domain/exploration/editable-exploration-backend-api.service';
import {PlayerPositionService} from '../services/player-position.service';
import {PlayerTranscriptService} from '../services/player-transcript.service';
import {StateCard} from 'domain/state_card/state-card.model';
import {RecordedVoiceovers} from 'domain/exploration/recorded-voiceovers.model';
import {AudioTranslationLanguageService} from '../services/audio-translation-language.service';
import {UserInfo} from 'domain/user/user-info.model';
import {UserService} from 'services/user.service';
import {
  Interaction,
  InteractionObjectFactory,
} from 'domain/exploration/InteractionObjectFactory';
import {UrlInterpolationService} from 'domain/utilities/url-interpolation.service';
import {WindowRef} from 'services/contextual/window-ref.service';
import {CheckpointCelebrationUtilityService} from 'pages/exploration-player-page/services/checkpoint-celebration-utility.service';
import {ConceptCardManagerService} from '../services/concept-card-manager.service';

class MockCheckpointCelebrationUtilityService {
  private _openLessonInformationModalEmitter = new EventEmitter<void>();

  getOpenLessonInformationModalEmitter(): EventEmitter<void> {
    return this._openLessonInformationModalEmitter;
  }

  openLessonInformationModal(): void {
    this._openLessonInformationModalEmitter.emit();
  }
}

class MockWindowRef {
  nativeWindow = {
    location: {
      pathname: '/learn/math',
      href: '',
      reload: () => {},
      toString: () => {
        return 'http://localhost:8181/?lang=es';
      },
    },
    localStorage: {
      last_uploaded_audio_lang: 'en',
      removeItem: (name: string) => {},
    },
    gtag: () => {},
    history: {
      pushState(data: object, title: string, url?: string | null) {},
    },
    document: {
      body: {
        style: {
          overflowY: 'auto',
        },
      },
    },
  };
}

class MockNgbModalRef {
  componentInstance = {
    skillId: null,
    explorationId: null,
  };
}

describe('ExplorationFooterComponent', () => {
  let component: ExplorationFooterComponent;
  let fixture: ComponentFixture<ExplorationFooterComponent>;
  let contextService: ContextService;
  let urlService: UrlService;
  let learnerViewInfoBackendApiService: LearnerViewInfoBackendApiService;
  let loggerService: LoggerService;
  let readOnlyExplorationBackendApiService: ReadOnlyExplorationBackendApiService;
  let windowDimensionsService: WindowDimensionsService;
  let questionPlayerStateService: QuestionPlayerStateService;
  let mockResizeEventEmitter = new EventEmitter();
  let explorationSummaryBackendApiService: ExplorationSummaryBackendApiService;
  let stateObjectFactory: StateObjectFactory;
  let explorationEngineService: ExplorationEngineService;
  let editableExplorationBackendApiService: EditableExplorationBackendApiService;
  let playerPositionService: PlayerPositionService;
  let playerTranscriptService: PlayerTranscriptService;
  let audioTranslationLanguageService: AudioTranslationLanguageService;
  let userService: UserService;
  let urlInterpolationService: UrlInterpolationService;
  let checkpointCelebrationUtilityService: CheckpointCelebrationUtilityService;
  let ngbModal: NgbModal;
  let conceptCardManagerService: ConceptCardManagerService;
  let interactionObjectFactory: InteractionObjectFactory;

  const sampleExpInfo = {
    category: 'dummy_category',
    community_owned: false,
    activity_type: 'dummy_type',
    last_updated_msec: 5000,
    ratings: {
      1: 0,
      2: 0,
      3: 0,
      4: 0,
      5: 0,
    },
    id: 'dummy_id',
    created_on_msec: 2000,
    human_readable_contributors_summary: {},
    language_code: 'en',
    num_views: 500,
    objective: 'dummy_objective',
    status: 'private',
    tags: ['tag1', 'tag2'],
    thumbnail_bg_color: 'bg_color_test',
    thumbnail_icon_url: 'icon_url',
    title: 'expTitle',
  };

  let mockResultsLoadedEventEmitter = new EventEmitter<boolean>();

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule, NgbModule],
      declarations: [
        ExplorationFooterComponent,
        MockTranslatePipe,
        LimitToPipe,
      ],
      providers: [
        QuestionPlayerStateService,
        LearnerViewInfoBackendApiService,
        LoggerService,
        UrlInterpolationService,
        {
          provide: CheckpointCelebrationUtilityService,
          useClass: MockCheckpointCelebrationUtilityService,
        },
        {
          provide: WindowRef,
          useClass: MockWindowRef,
        },
        {
          provide: TranslateService,
          useClass: MockTranslateService,
        },
      ],
      schemas: [NO_ERRORS_SCHEMA],
    }).compileComponents();
  }));

  beforeEach(() => {
    contextService = TestBed.inject(ContextService);
    urlService = TestBed.inject(UrlService);
    windowDimensionsService = TestBed.inject(WindowDimensionsService);
    learnerViewInfoBackendApiService = TestBed.inject(
      LearnerViewInfoBackendApiService
    );
    loggerService = TestBed.inject(LoggerService);
    readOnlyExplorationBackendApiService = TestBed.inject(
      ReadOnlyExplorationBackendApiService
    );
    explorationSummaryBackendApiService = TestBed.inject(
      ExplorationSummaryBackendApiService
    );
    editableExplorationBackendApiService = TestBed.inject(
      EditableExplorationBackendApiService
    );
    questionPlayerStateService = TestBed.inject(QuestionPlayerStateService);
    explorationEngineService = TestBed.inject(ExplorationEngineService);
    stateObjectFactory = TestBed.inject(StateObjectFactory);
    playerPositionService = TestBed.inject(PlayerPositionService);
    playerTranscriptService = TestBed.inject(PlayerTranscriptService);
    audioTranslationLanguageService = TestBed.inject(
      AudioTranslationLanguageService
    );
    userService = TestBed.inject(UserService);
    urlInterpolationService = TestBed.inject(UrlInterpolationService);
    checkpointCelebrationUtilityService = TestBed.inject(
      CheckpointCelebrationUtilityService
    );
    conceptCardManagerService = TestBed.inject(ConceptCardManagerService);
    fixture = TestBed.createComponent(ExplorationFooterComponent);
    ngbModal = TestBed.inject(NgbModal);
    interactionObjectFactory = TestBed.inject(InteractionObjectFactory);
    component = fixture.componentInstance;
    fixture.detectChanges();

    spyOn(playerPositionService, 'onNewCardOpened').and.returnValue(
      new EventEmitter<StateCard>()
    );
  });

  afterEach(() => {
    component.ngOnDestroy();
  });

  it(
    'should initialise component when user opens exploration ' + 'player',
    fakeAsync(() => {
      spyOn(contextService, 'getExplorationId').and.returnValue('exp1');
      spyOn(playerPositionService.onNewCardOpened, 'subscribe');
      spyOn(urlService, 'isIframed').and.returnValue(true);
      spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(false);
      spyOn(windowDimensionsService, 'getResizeEvent').and.returnValue(
        mockResizeEventEmitter
      );
      spyOn(playerPositionService.onLoadedMostRecentCheckpoint, 'subscribe');
      spyOn(conceptCardManagerService, 'reset');
      spyOn(
        checkpointCelebrationUtilityService.getOpenLessonInformationModalEmitter(),
        'subscribe'
      );
      spyOn(component, 'getCheckpointCount').and.returnValue(Promise.resolve());
      spyOn(component, 'showProgressReminderModal');
      spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(false);
      spyOn(contextService, 'getQuestionPlayerIsManuallySet').and.returnValue(
        true
      );
      spyOn(
        explorationSummaryBackendApiService,
        'loadPublicAndPrivateExplorationSummariesAsync'
      ).and.resolveTo({
        summaries: [
          {
            category: 'Coding',
            community_owned: true,
            thumbnail_bg_color: '#a33f40',
            title: 'Project Euler Problem 1',
            num_views: 263,
            tags: [],
            human_readable_contributors_summary: {
              contributor_1: {
                num_commits: 1,
              },
              contributor_2: {
                num_commits: 3,
              },
              contributor_3: {
                num_commits: 2,
              },
            },
            status: 'public',
            language_code: 'en',
            objective: 'Solve problem 1 on the Project Euler site',
            thumbnail_icon_url: '/subjects/Lightbulb.svg',
            id: 'exp1',
          } as ExplorationSummaryDict,
        ],
      });
      spyOn(userService, 'getUserInfoAsync').and.returnValue(
        Promise.resolve(
          new UserInfo([], false, false, false, false, false, '', '', '', true)
        )
      );

      // A StateCard which supports hints.
      let newCard = StateCard.createNewCard(
        'State 2',
        '<p>Content</p>',
        '<interaction></interaction>',
        interactionObjectFactory.createFromBackendDict({
          id: 'TextInput',
          answer_groups: [
            {
              outcome: {
                dest: 'State',
                dest_if_really_stuck: null,
                feedback: {
                  html: '',
                  content_id: 'This is a new feedback text',
                },
                refresher_exploration_id: 'test',
                missing_prerequisite_skill_id: 'test_skill_id',
                labelled_as_correct: true,
                param_changes: [],
              },
              rule_specs: [],
              training_data: [],
              tagged_skill_misconception_id: '',
            },
          ],
          default_outcome: {
            dest: 'Hola',
            dest_if_really_stuck: null,
            feedback: {
              content_id: '',
              html: '',
            },
            labelled_as_correct: true,
            param_changes: [],
            refresher_exploration_id: 'test',
            missing_prerequisite_skill_id: 'test_skill_id',
          },
          confirmed_unclassified_answers: [],
          customization_args: {
            rows: {
              value: true,
            },
            placeholder: {
              value: 1,
            },
          },
          hints: [],
          solution: {
            answer_is_exclusive: true,
            correct_answer: 'test_answer',
            explanation: {
              content_id: '2',
              html: 'test_explanation1',
            },
          },
        }),
        RecordedVoiceovers.createEmpty(),
        'content',
        audioTranslationLanguageService
      );

      component.ngOnInit();
      playerPositionService.onNewCardOpened.emit(newCard);
      tick();

      expect(component.explorationId).toBe('exp1');
      expect(component.iframed).toBeTrue();
      expect(component.windowIsNarrow).toBeFalse();
      expect(
        explorationSummaryBackendApiService.loadPublicAndPrivateExplorationSummariesAsync
      ).toHaveBeenCalledWith(['exp1']);
      expect(component.contributorNames).toEqual([
        'contributor_2',
        'contributor_3',
        'contributor_1',
      ]);
      expect(
        playerPositionService.onLoadedMostRecentCheckpoint.subscribe
      ).toHaveBeenCalled();
      expect(
        playerPositionService.onNewCardOpened.subscribe
      ).toHaveBeenCalled();
      expect(conceptCardManagerService.reset).toHaveBeenCalled();

      component.checkpointCount = 5;

      playerPositionService.onLoadedMostRecentCheckpoint.emit();

      expect(component.getCheckpointCount).toHaveBeenCalledTimes(1);
      expect(component.showProgressReminderModal).toHaveBeenCalled();

      component.checkpointCount = 0;

      playerPositionService.onLoadedMostRecentCheckpoint.emit();

      expect(component.getCheckpointCount).toHaveBeenCalledTimes(2);
    })
  );

  it('should check if progress reminder modal can be shown and show it', () => {
    const recentlyReachedCheckpointSpy = spyOn(
      component,
      'getMostRecentlyReachedCheckpointIndex'
    ).and.returnValue(1);
    spyOn(component, 'openProgressReminderModal');

    component.showProgressReminderModal();

    expect(component.openProgressReminderModal).not.toHaveBeenCalled();

    recentlyReachedCheckpointSpy.and.returnValue(3);
    component.expInfo = sampleExpInfo;

    component.showProgressReminderModal();

    expect(component.openProgressReminderModal).toHaveBeenCalled();
  });

  it('should fetch exploration info first if not present', fakeAsync(() => {
    spyOn(component, 'getMostRecentlyReachedCheckpointIndex').and.returnValue(
      3
    );
    spyOn(component, 'openProgressReminderModal');
    spyOn(
      learnerViewInfoBackendApiService,
      'fetchLearnerInfoAsync'
    ).and.returnValue(
      Promise.resolve({
        summaries: [
          {
            category: 'dummy_category',
            community_owned: false,
            activity_type: 'dummy_type',
            last_updated_msec: 5000,
            ratings: {
              1: 0,
              2: 0,
              3: 0,
              4: 0,
              5: 0,
            },
            id: 'dummy_id',
            created_on_msec: 2000,
            human_readable_contributors_summary: {},
            language_code: 'en',
            num_views: 500,
            objective: 'dummy_objective',
            status: 'private',
            tags: ['tag1', 'tag2'],
            thumbnail_bg_color: 'bg_color_test',
            thumbnail_icon_url: 'icon_url',
            title: 'expTitle',
          },
        ],
      })
    );

    component.showProgressReminderModal();

    expect(
      learnerViewInfoBackendApiService.fetchLearnerInfoAsync
    ).toHaveBeenCalled();
  }));

  it('should open progress reminder modal', fakeAsync(() => {
    const ngbModal = TestBed.inject(NgbModal);

    spyOn(ngbModal, 'open').and.returnValue({
      componentInstance: {
        checkpointCount: 0,
        completedCheckpointsCount: 0,
        explorationTitle: '',
      },
      result: Promise.resolve(),
    } as NgbModalRef);
    spyOn(
      editableExplorationBackendApiService,
      'resetExplorationProgressAsync'
    ).and.returnValue(Promise.resolve());

    const stateCard = new StateCard(
      'End',
      '<p>Testing</p>',
      null,
      new Interaction([], [], null, null, [], 'EndExploration', null),
      [],
      null,
      'content',
      null
    );

    const endState = {
      classifier_model_id: null,
      recorded_voiceovers: {
        voiceovers_mapping: {
          content: {},
        },
      },
      solicit_answer_details: false,
      interaction: {
        solution: null,
        confirmed_unclassified_answers: [],
        id: 'EndExploration',
        hints: [],
        customization_args: {
          recommendedExplorationIds: {
            value: ['recommendedExplorationId'],
          },
        },
        answer_groups: [],
        default_outcome: null,
      },
      param_changes: [],
      card_is_checkpoint: false,
      linked_skill_id: null,
      content: {
        content_id: 'content',
        html: 'Congratulations, you have finished!',
      },
    };

    component.expInfo = sampleExpInfo;
    component.checkpointCount = 2;
    spyOn(playerPositionService, 'getDisplayedCardIndex').and.returnValue(2);
    spyOn(explorationEngineService, 'getStateCardByName').and.returnValue(
      stateCard
    );
    spyOn(explorationEngineService, 'getState').and.returnValue(
      stateObjectFactory.createFromBackendDict('End', endState)
    );

    component.openProgressReminderModal();
    tick();
    fixture.detectChanges();

    expect(ngbModal.open).toHaveBeenCalled();
    expect(
      editableExplorationBackendApiService.resetExplorationProgressAsync
    ).toHaveBeenCalled();
  }));

  it(
    "should show 'Need help? Take a look at the concept" +
      " card for refreshing your concepts.' tooltip",
    () => {
      spyOn(
        conceptCardManagerService,
        'isConceptCardTooltipOpen'
      ).and.returnValues(true, false);

      expect(component.isTooltipVisible()).toBe(true);
      expect(component.isTooltipVisible()).toBe(false);
    }
  );

  it('should resume exploration if progress reminder modal is canceled', fakeAsync(() => {
    const ngbModal = TestBed.inject(NgbModal);

    spyOn(ngbModal, 'open').and.returnValue({
      componentInstance: {
        checkpointCount: 0,
        completedCheckpointsCount: 0,
        explorationTitle: '',
      },
      result: Promise.reject(),
    } as NgbModalRef);
    spyOn(
      editableExplorationBackendApiService,
      'resetExplorationProgressAsync'
    );

    const stateCard = new StateCard(
      'End',
      '<p>Testing</p>',
      null,
      new Interaction([], [], null, null, [], 'EndExploration', null),
      [],
      null,
      'content',
      null
    );

    const endState = {
      classifier_model_id: null,
      recorded_voiceovers: {
        voiceovers_mapping: {
          content: {},
        },
      },
      solicit_answer_details: false,
      interaction: {
        solution: null,
        confirmed_unclassified_answers: [],
        id: 'EndExploration',
        hints: [],
        customization_args: {
          recommendedExplorationIds: {
            value: ['recommendedExplorationId'],
          },
        },
        answer_groups: [],
        default_outcome: null,
      },
      param_changes: [],
      card_is_checkpoint: false,
      linked_skill_id: null,
      content: {
        content_id: 'content',
        html: 'Congratulations, you have finished!',
      },
    };

    component.expInfo = sampleExpInfo;
    component.checkpointCount = 2;
    spyOn(playerPositionService, 'getDisplayedCardIndex').and.returnValue(2);
    spyOn(explorationEngineService, 'getStateCardByName').and.returnValue(
      stateCard
    );
    spyOn(explorationEngineService, 'getState').and.returnValue(
      stateObjectFactory.createFromBackendDict('End', endState)
    );

    component.openProgressReminderModal();
    tick();
    fixture.detectChanges();

    expect(ngbModal.open).toHaveBeenCalled();
    expect(
      editableExplorationBackendApiService.resetExplorationProgressAsync
    ).not.toHaveBeenCalled();
  }));

  it(
    'should handle error if backend call to learnerViewInfoBackendApiService' +
      ' fails while opening progress reminder modal',
    fakeAsync(() => {
      component.explorationId = 'expId';
      component.expInfo = null;
      spyOn(
        learnerViewInfoBackendApiService,
        'fetchLearnerInfoAsync'
      ).and.returnValue(Promise.reject());
      spyOn(component, 'getMostRecentlyReachedCheckpointIndex').and.returnValue(
        3
      );
      spyOn(loggerService, 'error');

      component.showProgressReminderModal();
      tick();

      expect(loggerService.error).toHaveBeenCalled();
    })
  );

  it(
    'should not show hints after user finishes practice session' +
      ' and results are loaded.',
    () => {
      spyOn(contextService, 'getExplorationId').and.returnValue('exp1');
      spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(true);
      expect(component.hintsAndSolutionsAreSupported).toBeTrue();

      spyOnProperty(
        questionPlayerStateService,
        'resultsPageIsLoadedEventEmitter'
      ).and.returnValue(mockResultsLoadedEventEmitter);

      component.ngOnInit();
      mockResultsLoadedEventEmitter.emit(true);

      expect(component.hintsAndSolutionsAreSupported).toBeFalse();
    }
  );

  it('should open the lesson information card', fakeAsync(() => {
    let ngbModal = TestBed.inject(NgbModal);

    spyOn(ngbModal, 'open').and.returnValue({
      componentInstance: {
        numberofCheckpoints: 0,
        completedWidth: 0,
        contributorNames: [],
        expInfo: null,
      },
      result: {
        then: (successCallback: () => void, errorCallback: () => void) => {
          successCallback();
          errorCallback();
        },
      },
    } as NgbModalRef);

    let sampleExpResponse: FetchExplorationBackendResponse = {
      exploration_id: '0',
      displayable_language_codes: [],
      is_logged_in: true,
      session_id: 'KERH',
      draft_change_list_id: 0,
      exploration: {
        init_state_name: 'Introduction',
        param_changes: [],
        param_specs: null,
        title: 'Exploration',
        next_content_id_index: 5,
        language_code: 'en',
        objective: 'To learn',
        states: {
          Start: {
            classifier_model_id: null,
            recorded_voiceovers: {
              voiceovers_mapping: {
                ca_placeholder_0: {},
                feedback_1: {},
                rule_input_2: {},
                content: {},
                default_outcome: {},
              },
            },
            solicit_answer_details: false,
            interaction: {
              solution: null,
              confirmed_unclassified_answers: [],
              id: 'TextInput',
              hints: [],
              customization_args: {
                rows: {
                  value: 1,
                },
                placeholder: {
                  value: {
                    unicode_str: '',
                    content_id: 'ca_placeholder_0',
                  },
                },
                catchMisspellings: {
                  value: false,
                },
              },
              answer_groups: [
                {
                  outcome: {
                    missing_prerequisite_skill_id: null,
                    refresher_exploration_id: null,
                    labelled_as_correct: false,
                    feedback: {
                      content_id: 'feedback_1',
                      html: '<p>Good Job</p>',
                    },
                    param_changes: [],
                    dest_if_really_stuck: null,
                    dest: 'Mid',
                  },
                  training_data: [],
                  rule_specs: [
                    {
                      inputs: {
                        x: {
                          normalizedStrSet: ['answer'],
                          contentId: 'rule_input_2',
                        },
                      },
                      rule_type: 'FuzzyEquals',
                    },
                  ],
                  tagged_skill_misconception_id: null,
                },
              ],
              default_outcome: {
                missing_prerequisite_skill_id: null,
                refresher_exploration_id: null,
                labelled_as_correct: false,
                feedback: {
                  content_id: 'default_outcome',
                  html: '<p>Try again.</p>',
                },
                param_changes: [],
                dest_if_really_stuck: null,
                dest: 'Start',
              },
            },
            param_changes: [],
            card_is_checkpoint: true,
            linked_skill_id: null,
            content: {
              content_id: 'content',
              html: '<p>First Question</p>',
            },
          },
          End: {
            classifier_model_id: null,
            recorded_voiceovers: {
              voiceovers_mapping: {
                content: {},
              },
            },
            solicit_answer_details: false,
            interaction: {
              solution: null,
              confirmed_unclassified_answers: [],
              id: 'EndExploration',
              hints: [],
              customization_args: {
                recommendedExplorationIds: {
                  value: ['recommnendedExplorationId'],
                },
              },
              answer_groups: [],
              default_outcome: null,
            },
            param_changes: [],
            card_is_checkpoint: false,
            linked_skill_id: null,
            content: {
              content_id: 'content',
              html: 'Congratulations, you have finished!',
            },
          },
          Mid: {
            classifier_model_id: null,
            recorded_voiceovers: {
              voiceovers_mapping: {
                ca_placeholder_0: {},
                feedback_1: {},
                rule_input_2: {},
                content: {},
                default_outcome: {},
              },
            },
            solicit_answer_details: false,
            interaction: {
              solution: null,
              confirmed_unclassified_answers: [],
              id: 'TextInput',
              hints: [],
              customization_args: {
                rows: {
                  value: 1,
                },
                placeholder: {
                  value: {
                    unicode_str: '',
                    content_id: 'ca_placeholder_0',
                  },
                },
                catchMisspellings: {
                  value: false,
                },
              },
              answer_groups: [
                {
                  outcome: {
                    missing_prerequisite_skill_id: null,
                    refresher_exploration_id: null,
                    labelled_as_correct: false,
                    feedback: {
                      content_id: 'feedback_1',
                      html: ' <p>Good Job</p>',
                    },
                    param_changes: [],
                    dest_if_really_stuck: null,
                    dest: 'End',
                  },
                  training_data: [],
                  rule_specs: [
                    {
                      inputs: {
                        x: {
                          normalizedStrSet: ['answer'],
                          contentId: 'rule_input_2',
                        },
                      },
                      rule_type: 'FuzzyEquals',
                    },
                  ],
                  tagged_skill_misconception_id: null,
                },
              ],
              default_outcome: {
                missing_prerequisite_skill_id: null,
                refresher_exploration_id: null,
                labelled_as_correct: false,
                feedback: {
                  content_id: 'default_outcome',
                  html: '<p>try again.</p>',
                },
                param_changes: [],
                dest_if_really_stuck: null,
                dest: 'Mid',
              },
            },
            param_changes: [],
            card_is_checkpoint: true,
            linked_skill_id: null,
            content: {
              content_id: 'content',
              html: '<p>Second Question</p>',
            },
          },
        },
      },
      exploration_metadata: {
        title: 'Exploration',
        category: 'Algebra',
        objective: 'To learn',
        language_code: 'en',
        tags: [],
        blurb: '',
        author_notes: '',
        states_schema_version: 50,
        init_state_name: 'Introduction',
        param_specs: {},
        param_changes: [],
        auto_tts_enabled: false,
        edits_allowed: true,
      },
      version: 1,
      can_edit: true,
      preferred_audio_language_code: 'en',
      preferred_language_codes: [],
      auto_tts_enabled: true,
      record_playthrough_probability: 1,
      has_viewed_lesson_info_modal_once: false,
      furthest_reached_checkpoint_exp_version: 1,
      furthest_reached_checkpoint_state_name: 'Mid',
      most_recently_reached_checkpoint_state_name: 'Mid',
      most_recently_reached_checkpoint_exp_version: 1,
    };

    spyOn(
      readOnlyExplorationBackendApiService,
      'loadLatestExplorationAsync'
    ).and.returnValue(Promise.resolve(sampleExpResponse));

    component.checkpointCount = 2;

    spyOn(component, 'getMostRecentlyReachedCheckpointIndex').and.returnValue(
      2
    );
    spyOn(explorationEngineService, 'getState').and.returnValue(
      stateObjectFactory.createFromBackendDict(
        'End',
        sampleExpResponse.exploration.states.End
      )
    );

    let stateCard = new StateCard(
      'End',
      '<p>Testing</p>',
      null,
      new Interaction([], [], null, null, [], 'EndExploration', null),
      [],
      null,
      'content',
      null
    );

    spyOn(explorationEngineService, 'getStateCardByName').and.returnValue(
      stateCard
    );
    spyOn(playerPositionService, 'getDisplayedCardIndex').and.returnValue(2);
    component.openInformationCardModal();
    tick();
    fixture.detectChanges();

    expect(ngbModal.open).toHaveBeenCalled();
    expect(component.lastCheckpointWasCompleted).toEqual(true);
    expect(component.completedCheckpointsCount).toEqual(2);
  }));

  it('should open concept card when user clicks on the icon', () => {
    const modalSpy = spyOn(ngbModal, 'open').and.callFake((dlg, opt) => {
      return {
        componentInstance: MockNgbModalRef,
        result: Promise.resolve(),
      } as NgbModalRef;
    });
    component.openConceptCardModal();
    expect(modalSpy).toHaveBeenCalled();
  });

  it('should trigger function to open concept card modal', fakeAsync(() => {
    spyOn(component, 'openConceptCardModal');

    const endState = {
      classifier_model_id: null,
      recorded_voiceovers: {
        voiceovers_mapping: {
          content: {},
        },
      },
      solicit_answer_details: false,
      written_translations: {
        translations_mapping: {
          content: {},
        },
      },
      interaction: {
        solution: null,
        confirmed_unclassified_answers: [],
        id: 'EndExploration',
        hints: [],
        customization_args: {
          recommendedExplorationIds: {
            value: ['recommendedExplorationId'],
          },
        },
        answer_groups: [],
        default_outcome: null,
      },
      param_changes: [],
      next_content_id_index: 0,
      card_is_checkpoint: false,
      linked_skill_id: 'Id',
      content: {
        content_id: 'content',
        html: 'Congratulations, you have finished!',
      },
    };
    spyOn(explorationEngineService, 'getState').and.returnValue(
      stateObjectFactory.createFromBackendDict('End', endState)
    );

    component.showConceptCard();

    expect(component.linkedSkillId).toEqual('Id');
    expect(component.openConceptCardModal).toHaveBeenCalled();
  }));

  it('should display lesson information card', fakeAsync(() => {
    component.explorationId = 'exp1';
    component.expInfo = {} as LearnerExplorationSummaryBackendDict;

    spyOn(component, 'openInformationCardModal');
    component.showInformationCard();
    spyOn(
      learnerViewInfoBackendApiService,
      'fetchLearnerInfoAsync'
    ).and.returnValue(
      Promise.resolve({
        summaries: [],
      })
    );

    expect(component.openInformationCardModal).toHaveBeenCalled();
    component.expInfo = null;

    component.showInformationCard();
    tick();

    expect(
      learnerViewInfoBackendApiService.fetchLearnerInfoAsync
    ).toHaveBeenCalled();
  }));

  it('should get footer image url', () => {
    spyOn(urlInterpolationService, 'getStaticImageUrl').and.returnValue(
      'dummy_image_url'
    );

    expect(component.getStaticImageUrl('general/apple.svg')).toEqual(
      'dummy_image_url'
    );
    expect(urlInterpolationService.getStaticImageUrl).toHaveBeenCalledWith(
      'general/apple.svg'
    );
  });

  it('should get checkpoint index from state name', fakeAsync(() => {
    spyOn(contextService, 'getExplorationId').and.returnValue('exp1');
    spyOn(playerTranscriptService, 'getNumCards').and.returnValue(1);
    const card = StateCard.createNewCard(
      'State A',
      '<p>Content</p>',
      '<interaction></interaction>',
      null,
      RecordedVoiceovers.createEmpty(),
      'content',
      audioTranslationLanguageService
    );
    spyOn(playerTranscriptService, 'getCard').and.returnValue(card);
    spyOn(explorationEngineService, 'getStateFromStateName').and.returnValue(
      stateObjectFactory.createFromBackendDict('State A', {
        classifier_model_id: null,
        content: {
          html: '',
          content_id: 'content',
        },
        interaction: {
          id: 'FractionInput',
          customization_args: {
            requireSimplestForm: {value: false},
            allowImproperFraction: {value: true},
            allowNonzeroIntegerPart: {value: true},
            customPlaceholder: {
              value: {
                content_id: '',
                unicode_str: '',
              },
            },
          },
          answer_groups: [],
          default_outcome: {
            dest: 'Introduction',
            dest_if_really_stuck: null,
            feedback: {
              content_id: 'default_outcome',
              html: '',
            },
            labelled_as_correct: false,
            param_changes: [],
            refresher_exploration_id: null,
            missing_prerequisite_skill_id: null,
          },
          confirmed_unclassified_answers: [],
          hints: [],
          solution: null,
        },
        linked_skill_id: null,
        param_changes: [],
        recorded_voiceovers: {
          voiceovers_mapping: {
            content: {},
            default_outcome: {},
          },
        },
        solicit_answer_details: false,
        card_is_checkpoint: true,
      })
    );

    let checkpointIndex = component.getMostRecentlyReachedCheckpointIndex();
    tick();
    fixture.detectChanges();
    expect(checkpointIndex).toEqual(1);
  }));

  it(
    'should handle error if backend call' +
      'to learnerViewInfoBackendApiService fails',
    fakeAsync(() => {
      let explorationId = 'expId';
      component.explorationId = explorationId;
      component.expInfo = null;

      spyOn(
        learnerViewInfoBackendApiService,
        'fetchLearnerInfoAsync'
      ).and.returnValue(Promise.reject());
      spyOn(loggerService, 'error');

      component.showInformationCard();
      tick();

      expect(loggerService.error).toHaveBeenCalled();
    })
  );

  it('should fetch number of checkpoints correctly', fakeAsync(() => {
    let sampleDataResults: FetchExplorationBackendResponse = {
      exploration_id: 'expId',
      displayable_language_codes: [],
      is_logged_in: true,
      session_id: 'KERH',
      exploration: {
        init_state_name: 'Introduction',
        next_content_id_index: 5,
        param_changes: [],
        param_specs: null,
        title: 'Exploration',
        language_code: 'en',
        objective: 'To learn',
        states: {
          Introduction: {
            param_changes: [],
            classifier_model_id: null,
            recorded_voiceovers: null,
            solicit_answer_details: true,
            card_is_checkpoint: true,
            linked_skill_id: null,
            content: {
              html: '',
              content_id: 'content',
            },
            interaction: {
              customization_args: {},
              answer_groups: [],
              solution: null,
              hints: [],
              default_outcome: {
                param_changes: [],
                dest_if_really_stuck: null,
                dest: 'Introduction',
                feedback: {
                  html: '',
                  content_id: 'content',
                },
                labelled_as_correct: true,
                refresher_exploration_id: 'exp',
                missing_prerequisite_skill_id: null,
              },
              confirmed_unclassified_answers: [],
              id: null,
            },
          },
        },
      },
      exploration_metadata: {
        title: 'Exploration',
        category: 'Algebra',
        objective: 'To learn',
        language_code: 'en',
        tags: [],
        blurb: '',
        author_notes: '',
        states_schema_version: 50,
        init_state_name: 'Introduction',
        param_specs: {},
        param_changes: [],
        auto_tts_enabled: false,
        edits_allowed: true,
      },
      version: 1,
      can_edit: true,
      preferred_audio_language_code: 'en',
      preferred_language_codes: [],
      auto_tts_enabled: true,
      record_playthrough_probability: 1,
      draft_change_list_id: 0,
      has_viewed_lesson_info_modal_once: false,
      furthest_reached_checkpoint_exp_version: 1,
      furthest_reached_checkpoint_state_name: 'State B',
      most_recently_reached_checkpoint_state_name: 'State A',
      most_recently_reached_checkpoint_exp_version: 1,
    };

    component.explorationId = 'expId';

    spyOn(
      readOnlyExplorationBackendApiService,
      'fetchExplorationAsync'
    ).and.returnValue(Promise.resolve(sampleDataResults));
    expect(component.checkpointCount).toEqual(0);

    component.getCheckpointCount();
    tick();

    expect(component.expStates).toEqual(sampleDataResults.exploration.states);
    expect(component.checkpointCount).toEqual(1);
  }));

  it('should check if user has viewed lesson info once', fakeAsync(() => {
    let sampleDataResults: FetchExplorationBackendResponse = {
      exploration_id: 'expId',
      displayable_language_codes: [],
      is_logged_in: true,
      session_id: 'KERH',
      exploration: {
        init_state_name: 'Introduction',
        param_changes: [],
        param_specs: null,
        title: 'Exploration',
        next_content_id_index: 5,
        language_code: 'en',
        objective: 'To learn',
        states: {
          Introduction: {
            param_changes: [],
            classifier_model_id: null,
            recorded_voiceovers: null,
            solicit_answer_details: true,
            card_is_checkpoint: true,
            linked_skill_id: null,
            content: {
              html: '',
              content_id: 'content',
            },
            interaction: {
              customization_args: {},
              answer_groups: [],
              solution: null,
              hints: [],
              default_outcome: {
                param_changes: [],
                dest: 'Introduction',
                dest_if_really_stuck: null,
                feedback: {
                  html: '',
                  content_id: 'content',
                },
                labelled_as_correct: true,
                refresher_exploration_id: 'exp',
                missing_prerequisite_skill_id: null,
              },
              confirmed_unclassified_answers: [],
              id: null,
            },
          },
        },
      },
      exploration_metadata: {
        title: 'Exploration',
        category: 'Algebra',
        objective: 'To learn',
        language_code: 'en',
        tags: [],
        blurb: '',
        author_notes: '',
        states_schema_version: 50,
        init_state_name: 'Introduction',
        param_specs: {},
        param_changes: [],
        auto_tts_enabled: false,
        edits_allowed: true,
      },
      version: 1,
      can_edit: true,
      preferred_audio_language_code: 'en',
      preferred_language_codes: [],
      auto_tts_enabled: true,
      record_playthrough_probability: 1,
      draft_change_list_id: 0,
      has_viewed_lesson_info_modal_once: false,
      furthest_reached_checkpoint_exp_version: 1,
      furthest_reached_checkpoint_state_name: 'State B',
      most_recently_reached_checkpoint_state_name: 'State A',
      most_recently_reached_checkpoint_exp_version: 1,
    };

    spyOn(
      readOnlyExplorationBackendApiService,
      'fetchExplorationAsync'
    ).and.returnValue(Promise.resolve(sampleDataResults));

    component.explorationId = 'expId';

    component.setLearnerHasViewedLessonInfoTooltip();
    tick();

    expect(component.hasLearnerHasViewedLessonInfoTooltip()).toBeFalse();
  }));

  it('should correctly mark lesson info tooltip as viewed', () => {
    spyOn(
      editableExplorationBackendApiService,
      'recordLearnerHasViewedLessonInfoModalOnce'
    ).and.returnValue(Promise.resolve());
    expect(component.hasLearnerHasViewedLessonInfoTooltip()).toBeFalse();
    component.userIsLoggedIn = true;
    component.learnerHasViewedLessonInfo();
    expect(component.hasLearnerHasViewedLessonInfoTooltip()).toBeTrue();
    expect(
      editableExplorationBackendApiService.recordLearnerHasViewedLessonInfoModalOnce
    ).toHaveBeenCalled();
  });

  it(
    'should show hints when initialized in question player when user is' +
      ' going through the practice session and should add subscription.',
    () => {
      spyOn(contextService, 'getExplorationId').and.returnValue('expId');
      spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(true);
      spyOn(
        questionPlayerStateService.resultsPageIsLoadedEventEmitter,
        'subscribe'
      );

      component.ngOnInit();

      expect(component.hintsAndSolutionsAreSupported).toBeTrue();
      expect(
        questionPlayerStateService.resultsPageIsLoadedEventEmitter.subscribe
      ).toHaveBeenCalled();
    }
  );

  it('should check if window is narrow when user resizes window', () => {
    spyOn(contextService, 'getExplorationId').and.returnValue('exp1');
    spyOn(urlService, 'isIframed').and.returnValue(true);
    spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(false);
    spyOn(windowDimensionsService, 'getResizeEvent').and.returnValue(
      mockResizeEventEmitter
    );
    spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(false);
    spyOn(contextService, 'getQuestionPlayerIsManuallySet').and.returnValue(
      false
    );
    component.windowIsNarrow = true;

    component.ngOnInit();
    mockResizeEventEmitter.emit();

    expect(component.windowIsNarrow).toBeFalse();
  });

  it('should open lesson info modal if emitter emits', () => {
    spyOn(contextService, 'getExplorationId').and.returnValue('expId');
    spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(true);
    spyOn(
      checkpointCelebrationUtilityService.getOpenLessonInformationModalEmitter(),
      'subscribe'
    ).and.callThrough();
    spyOn(component, 'showInformationCard');

    component.ngOnInit();
    checkpointCelebrationUtilityService.openLessonInformationModal();

    expect(
      checkpointCelebrationUtilityService.getOpenLessonInformationModalEmitter()
        .subscribe
    ).toHaveBeenCalled();
    expect(component.showInformationCard).toHaveBeenCalled();
  });

  it(
    'should not display author names when exploration is in question' +
      ' player mode',
    () => {
      spyOn(contextService, 'getExplorationId').and.returnValue('exp1');
      spyOn(urlService, 'isIframed').and.returnValue(true);
      spyOn(windowDimensionsService, 'isWindowNarrow').and.returnValue(false);
      spyOn(windowDimensionsService, 'getResizeEvent').and.returnValue(
        mockResizeEventEmitter
      );
      spyOn(contextService, 'isInQuestionPlayerMode').and.returnValue(true);
      spyOn(contextService, 'getQuestionPlayerIsManuallySet').and.returnValue(
        false
      );
      spyOn(
        explorationSummaryBackendApiService,
        'loadPublicAndPrivateExplorationSummariesAsync'
      );

      component.ngOnInit();

      expect(
        explorationSummaryBackendApiService.loadPublicAndPrivateExplorationSummariesAsync
      ).not.toHaveBeenCalled();
    }
  );
});