三、通过测试构建滑块益智游戏

在上一章中,我们使用 Vue 创建了一个简单的 GitHub 应用,其中添加了一些组件。在本章中,我们将构建一个简单的滑块益智游戏。游戏的目标是重新排列图片的各个部分,直到它看起来像我们期望的那样。它将有一个计时器来计算经过的时间,并将其显示在屏幕上。一旦我们正确地重新排列了图像的各个部分,我们将看到一条‘You Win’消息,如果它是前 10 个最快的时间,则经过的时间将记录在本地存储器中。我们有多个谜题可供选择,这样我们可以在游戏中有更多的多样性。这使得它比一个拼图更有趣。

为了构建应用,我们将构建具有计算属性和计时器的组件来计算运行时间。此外,一些组件将从本地存储器获取和设置数据。无论何时我们从本地存储器获取数据,结果都会显示出来。我们将使用本地存储来存储最快的时间。本地存储只能存储字符串,因此我们将结果转换为字符串并存储它。

我们将使用计时器来计时玩家何时赢得游戏,并使用计算出的属性来确定玩家何时赢得游戏。此外,为了确保我们的游戏正常运行,我们将为每个部分添加单元测试,以自动测试每个组件。

在本章中,我们将深入了解组件并涵盖以下主题:

  • 了解组件和 mixin 的基础知识
  • 设置我们的 Vue 项目
  • 创建用于洗牌图片的组件
  • 让用户重新排列幻灯片
  • 根据时间计算分数
  • 用 Jest 进行单元测试

技术要求

本章的源代码位于https://github.com/PacktPublishing/-Vue.js-3-By-Example/tree/master/Chapter03

了解组件和混合器的基础知识

组件比我们在第 2 章构建 Vue 3 渐进式 Web 应用中所做的更多,以创建 GitHub 渐进式 Web 应用。这些部件是最基本的部件。我们将在组件中使用计时器,而不仅仅是使用组件获取数据并显示数据。此外,我们还将了解何时以及如何使用计算属性,以便创建具有从其他反应属性派生的值的反应属性。这样可以避免创建我们不需要的额外方法或不必要地使用指令。

此外,我们将研究如何使用计算属性返回从其他反应属性派生的值。计算属性是返回从一个或多个其他反应属性导出的值的方法。它们本身就是反应性质。它们最常用的用法是 getter。但是,计算属性可以同时具有 getter 和 setter。缓存它们的返回值,以便在一个或多个被动属性的值更新之前不会运行它们。它们有助于高效地替换复杂的模板表达式和方法。

组件可以做的另一件事是发出自定义事件。事件可以包含随事件一起发出的一个或多个有效负载。它们有自己的事件名称,我们可以通过使用[T0]指令监听事件来监听事件。我们可以使用$event变量或事件处理程序方法的参数来获得发出的有效负载。

Vue 3 应用的另一个重要部分是测试。当我们提到测试时,它们通常是自动测试。测试有多种形式,可用于捕获各种错误。它们通常用于捕捉回归,回归是在我们更改已经是应用一部分的代码后创建的 bug。我们可以通过几种测试来检查回归。我们可以创建的最小的测试是单元测试,它单独测试组件及其部件。它通过在测试环境中安装我们的组件来工作。任何阻止我们的测试在隔离状态下运行的依赖项都会被模拟,以便我们可以在隔离状态下运行测试。这样,我们可以在任何环境中以任何顺序运行测试。

每个测试都是独立的,所以我们在任何地方运行它们都不会有任何问题,即使没有互联网连接。这很重要,因为它们应该是便携式的。此外,API 数据和计时器等外部资源非常不稳定。它们也是异步的,这使得它们很难测试。因此,我们必须确保我们的测试不需要它们,因为我们希望结果的一致性。

Vue 支持 JavaScript 测试框架,如JestMocha。这是使用 Vue CLI 创建我们的 Vue 项目的一大好处。我们不必自己创建测试代码的所有框架。

另一种测试是端到端测试。这些测试模拟用户将如何使用我们的应用。我们通常有一个从头创建的环境,然后运行这些测试。这是因为我们希望测试中随时都有新的数据。测试必须能够以一致的方式运行。如果我们要像用户一样使用应用,我们需要一致的数据来完成这项工作。

在本章中,我们将主要关注前端应用的单元测试。它们可以像我们使用端到端测试一样提供 DOM 交互,但它们速度更快,体积更小。它们也运行得更快,因为我们不必每次运行测试时都创建一个干净的环境。环境的创建和用户交互测试总是比单元测试慢。因此,我们应该有许多单元测试和一些端到端测试来测试我们应用的最关键部分。

建立 Vue 项目

现在我们已经了解了有关计算属性、getter 和 setter 的基础知识,我们准备更深入地了解我们将需要的组件并创建项目。

要创建项目,我们再次使用 Vue CLI。这一次,我们必须选择几个选项,而不是选择默认选项。但在此之前,我们将创建一个名为vue-example-ch3-slider-puzzle的项目文件夹。然后,我们必须进入文件夹并使用npm运行以下命令:

  1. 首先,我们必须全局安装 Vue CLI,以便使用它创建和运行项目:

    js npm install -g @vue/cli@next

  2. 现在,我们可以进入我们的项目文件夹并运行以下命令来创建我们的项目:

    js vue create .

同样地,我们可以对纱线运行以下命令:

  1. 首先,我们必须全局安装 Vue CLI,以便使用它创建和运行项目:

    js yarn global add @vue/cli@next

  2. 然后,我们可以进入我们的项目文件夹并运行以下命令来创建我们的项目:

    js yarn create .

无论是哪种情况,我们都应该看到 Vue CLI 命令行程序,其中包含有关如何选择项目的说明。如果询问我们是否要在当前文件夹中创建项目,我们可以键入Y并按Enter进行创建。然后,我们应该看到可以用来创建项目的项目类型。我们应该选择Manually select features,然后选择Vue 3创建一个 Vue 3 项目:

Figure 3.1 – Selecting the project type to create in the Vue CLI wizard

图 3.1–选择要在 Vue CLI 向导中创建的项目类型

在下一个屏幕上,我们应该看到可以添加到项目中的内容。选择Unit``Testing,,然后您需要选择Testing``with``Jest,以便我们可以向我们的应用添加测试。

在我们完成代码编写后,该项目将对许多组件进行测试:

Figure 3.2 – The options we should choose for this project

图 3.2–我们应该为该项目选择的选项

一旦我们让 Vue CLI 完成项目的创建,我们应该会在src文件夹中看到代码文件。测试应在tests/unit文件夹中。Vue CLI 为我们节省了大量的精力,使我们可以自己创建测试代码。它附带了一个我们可以扩展的示例测试。

一旦我们选择了这些选项,我们就可以开始创建我们的应用了。在这个项目中,我们将从 Unsplash 获得一些图片,它为我们提供免版税的图片。然后,我们将获得图像并将其切割成九块,以便我们可以在slider puzzle组件中显示它们。我们需要整个图像和切块。对于本例,我们将从以下链接获取图像:

当我们进入每个页面时,我们必须点击下载按钮来下载图像。下载完图片后,我们必须转到https://www.imgonline.com.ua/eng/cut-photo-into-pieces.php 自动将图像切割成九块。

第 1节中,我们选择我们的图像文件。在第 2 节中,我们将部分的宽度部分的高度设置为3。这样,我们可以把我们的图像分成九块。完成后,我们可以下载生成的 ZIP 文件,然后将所有图像解压缩到一个文件夹中。对于每个图像都应重复此操作。

一旦我们有了所有的整体和剪切图像块,我们应该把它们都放到我们刚刚创建的 Vue 3 项目文件夹的src/assets文件夹中。这样,我们就可以从应用中访问图像并显示它们。第一张图片显示的是一朵粉红色的花,因此整个图片被命名为pink.jpg,而剪切的图片则在cut-pink文件夹中。为剪切图像生成的文件名保持不变。第二张图片是一朵紫色的花,所以整个图片被命名为purple.jpg,而剪切的图片文件夹被命名为cut-purple。第三个图像是一朵红花。因此,它被命名为red.jpg,包含图像切块的文件夹被命名为cut-red

现在我们已经处理了图像,我们可以创建组件了。

首先,我们必须从src/components文件夹中删除HelloWorld.vue,因为我们不再需要它了。我们还必须从App.vue文件中删除对它的任何引用。

接下来,在components文件夹中,我们必须创建Puzzles.vue文件,让我们选择拼图。它有一个模板,这样我们可以显示我们选择的拼图。在component options对象中,我们有一个数组,其中包含要显示的拼图数据。此外,我们还有一种方法,可以将事件发送到父组件,即App.vue组件。这样,我们可以在我们将创建的滑块拼图组件中显示正确的拼图。为此,我们必须在src/components/Puzzles.vue中添加以下模板代码:

<template>
  <div>
    <h1>Select a Puzzle</h1>
    <div v-for="p of puzzles" :key="p.id" class="row">
      <div>
        <img :src="require(`img/${p.image}`)" />
      </div>
      <div>
        <h2>{{p.title}}</h2>
      </div>
      <div class="play-button">
        <button @click="selectPuzzle(p)">Play</button>
      </div>
    </div>
  </div>
</template>

然后,我们必须添加以下脚本和样式标签:

<script>
export default {
  data() {
    return {
      puzzles: [
        { id: 'cut-pink', image: "pink.jpg", title: "Pink 
          Flower" },
        { id: 'cut-purple', image: "purple.jpg", title: 
          "Purple Flower" },
        { id: 'cut-red', image: "red.jpg", title: "Red 
          Flower" },
      ],
    };
  },
...
<style scoped>
.row {
  display: flex;
  max-width: 90vw;
  flex-wrap: wrap;
  justify-content: space-between;
}
.row img {
  width: 100px;
}
.row .play-button {
  padding-top: 25px;
}
</style>

在的component options对象中,我们有data()方法,在脚本标记之间具有谜题的反应属性。它有一个具有idimagetitle属性的对象数组。id属性是我们在使用v-for指令呈现条目时使用的唯一 ID。我们还将 ID 发送到App.vue,以便将其作为道具传递给我们的幻灯片拼图组件。title是我们以人类可读的方式在模板上显示的标题。

methods属性中,我们有一个selectPuzzle()方法来获取拼图对象。它调用this.$emit来发出谜题更改事件。第一个参数是name。第二个参数是我们希望在事件中发出的payload属性。我们可以通过在引用该组件的任何位置向元素添加一个v-on指令来侦听父组件中的事件。

在模板中,titleh1组件一起显示。v-for指令循环通过拼图的array反应属性中的项目并显示它们。与往常一样,我们需要将每个条目的key属性设置为 Vue 3 的唯一 ID,以便正确跟踪这些值。我们还必须添加一个class属性,以便我们可以设置行的样式。要显示图像,我们可以调用require,以便 Vue 3 可以直接解析路径。Vue CLI 使用 Webpack,以便可以将映像作为模块加载。我们可以将其设置为src道具的值,它将显示图像。我们加载整个图像并显示它们。

另外,在行中,我们有一个按钮,当我们点击它时,它会调用selectPuzzle()方法。这将设置拼图的选择,并将其传播到我们将创建的滑块拼图组件,以便我们可以看到显示的正确拼图。

.row img select将其宽度设置为100px以显示整个图像的缩略图。此外,我们还可以以与其他子元素对齐的方式显示按钮。

接下来,我们必须创建src/components/Records.vue文件以添加包含速度记录的组件。这提供了赢得比赛的最快时间列表。最快的时间记录存储在本地存储器中,便于访问。在这个组件中,我们所做的就是显示组件。

要创建此组件,我们必须在src/components/Records.vue中编写以下代码:

<template>
  <div>
    <h1>Records</h1>
    <button @click="getRecords">Refresh</button>
    <div v-for="(r, index) of records" :key="index">{{
      index + 1}} - {{r.elapsedTime}}</div>
  </div>
</template>
<script>
export default {
  data() {
    return {
      records: [],
    };
  },
  created() {
    this.getRecords();
  },
  methods: {
    getRecords() {
      const records = JSON.parse(localStorage.getItem(
        "records")) || [];
      this.records = records;
    },
  },
};
</script>

在的component对象中,我们有getRecords()方法,它从本地存储中获取最快的时间记录。localStorage.getItem()方法通过其键获取数据。参数是映射到我们想要获取的数据的关键。它返回一个包含数据的字符串。因此,要将字符串转换为对象,必须调用JSON.parse将 JSON 字符串解析为对象。它应该是一个数组,因为我们将创建一个数组,并在记录它之前将其字符串化为 JSON 字符串。本地存储只能保存字符串;因此,这是一个必要的步骤。

一旦我们从本地存储中检索到记录,我们就可以将其设置为[T0]reactive 属性的值。如果本地存储中没有带[T1]键的项,我们必须将默认值设置为空数组。这样,我们总是将一个数组分配给this.records

此外,我们还有beforeMount钩子,它可以让我们在安装组件之前获取记录。这样,我们将在安装组件时看到记录。

在模板中,我们使用v-for指令显示速度记录,以循环遍历项目并显示它们。数组项中的[T1]指令在括号中有第一项。括号中的第二项是索引。我们可以将key属性设置为索引,因为它们是唯一的,并且我们不会移动条目。我们在列表中同时显示这两个选项。

此外,我们还有一个按钮,当我们点击getRecords方法以获取最新条目时,它会调用该方法。

现在我们已经创建了最简单的组件,我们可以继续创建滑块拼图组件。

创建用于洗牌图片的组件

滑块拼图游戏提供了一个滑块拼图,玩家在其中将瓷砖洗牌到一张图片中以获胜,经过的时间显示,重新排列拼图的逻辑,检查我们是否获胜的逻辑,以及一个计时器以计算自游戏开始以来经过的时间。

为了方便地计算经过的时间,我们可以使用moment库。要安装库,我们可以运行npm install moment。一旦我们安装了软件包,我们就可以开始编写必要的代码了。

让我们创建src/components/SliderPuzzle.vue文件。此文件的完整代码可在中找到 https://github.com/PacktPublishing/-Vue.js-3-By-Example/blob/master/Chapter03/src/components/SliderPuzzle.vue

我们将首先创建带有script标记的组件:

<script>
import moment from "moment";
const correctPuzzleArray = [
  "image_part_001.jpg",
  "image_part_002.jpg",
  "image_part_003.jpg",
  "image_part_004.jpg",
  "image_part_005.jpg",
  "image_part_006.jpg",
  "image_part_007.jpg",
  "image_part_008.jpg",
  "image_part_009.jpg",
];
...
</script>

首先,我们导入moment库来计算经过的时间。接下来,我们定义correctPuzzleArray变量,并将其分配给具有正确文件顺序的数组。我们检查此数组以确定玩家是否赢得了游戏。

然后,我们继续为组件选项创建对象。props属性包含我们自己的道具。puzzleId是一个带有玩家正在玩的拼图 ID 的字符串。我们必须确保它是一个字符串。我们将其默认值设置为'cut-pink',以便始终有一个谜题集。

data()方法包含我们的初始状态。我们返回一个带有它们的对象。通过这种方式,我们确保反应性属性的值始终与应用中的其他组件隔离。correctPuzzleArray反应性质正是我们之前定义的。我们只是将它设置为一个属性,使它成为一个反应性属性。这使得它可用于我们的isWinning计算属性,因为我们希望在该数组更新时更新该值:

<script>
...
export default {
  name: "SliderPuzzle",
  props: {
    puzzleId: {
      type: String,
      default: "cut-pink",
    },
  },
  data() {
    return {
      correctPuzzleArray,
      shuffledPuzzleArray: [...correctPuzzleArray].sort(
        () => Math.random() - 0.5
      ),
      indexesToSwap: [],
      timer: undefined,
      startDateTime: new Date(),
      currentDateTime: new Date(),
    };
  },
  ...
};
</script>

shuffledPuzzleArraycorrectPuzzleArray反应属性的副本,但物品会被洗牌,因此玩家必须重新排列物品才能赢得游戏。要为属性创建值,首先,我们必须使用 spread 操作符复制[T2]数组。那么,我们必须用一个callback来调用sortcallback是一个函数,生成一个介于-0.50.5之间的数字,带有Math.random()0.5。我们需要一个介于该范围之间的随机数,以便值可以随机排序。callback是一个比较函数。它可以采用两个参数;即上一个和当前数组项,以便我们可以比较它们:

<script>
...
export default {
  ...
  computed: {
    isWinning() {
      for (let i = 0; i < correctPuzzleArray.length; i++) {
        if (correctPuzzleArray[i] !== 
          this.shuffledPuzzleArray[i]) {
          return false;
        }
      }
      return true;
    },
    elapsedDiff() {
      const currentDateTime = moment(this.currentDateTime);
      const startDateTime = moment(this.startDateTime);
      return currentDateTime.diff(startDateTime);
    },
    elapsedTime() {
      return moment.utc(this.elapsedDiff).format(
        "HH:mm:ss");
    },
  },
};
</script>

因为我们是随机排序项目,所以我们不需要做任何比较。如果比较器回调返回负数或[T0],则项目顺序不变。否则,我们正在排序的数组中的项的顺序将切换。sort()方法返回一个新数组,其中包含已排序的条目。

indexesToSwapreactive 属性用于添加要交换的图像文件名的索引。当我们点击swap()方法时,我们将一个新值推送到indexesToSwap被动属性,这样当indexesToSwap数组中有两个项目时,我们就可以用给定的索引交换这两个项目。

timerreactive 属性可能包含setInterval函数返回的计时器对象。setInterval函数允许我们定期运行代码。它接受一个回调,其中包含我们要作为第一个参数运行的代码。第二个参数是每次回调调用之间的时间(毫秒)。

startDateTimereactive 属性包含游戏开始的日期和时间。包含当前时间的Date实例。currentDateTimereactive 属性具有当前日期和时间的Date实例。当游戏在我们传递到setInterval函数的callback属性中进行处理时,它会被更新。

data()方法包含所有反应性质的初始值。

computed属性包含计算的属性。计算属性是同步函数,返回一些基于其他反应属性的值。计算属性本身就是反应属性。当被引用的计算特性函数中引用的反应特性被更新时,它们的值将被更新。我们在此组件中定义了三个计算属性:isWinningelapsedDiffelapsedTime

isWinning计算属性是包含游戏状态的属性。如果返回true,则玩家赢得游戏。否则,玩家就不会赢得比赛。为了检查玩家是否赢得了游戏,我们循环通过correctPuzzleArray反应属性,并检查它的每个条目是否与shuffledPuzzleArray反应属性数组中的条目相同。

correctPuzzleArray包含列出的正确项目。因此,如果shuffledPuzzleArray数组中的每个项目的反应属性与correctPuzzleArray中的条目匹配,那么我们就知道玩家赢了。否则,玩家不会赢。因此,如果correctPuzzleArrayshuffledPuzzleArray之间存在任何差异,则返回 false。否则,它将返回 true。

elapsedDiffcomputed 属性以毫秒为单位计算经过的时间。在这里,我们使用moment库来计算从startDateTimecurrentDateTime所经过的时间。我们使用moment库进行此计算,因为它使我们的工作更容易。它有一个diff()方法,我们可以用来计算这个和另一个moment对象之间的差异。返回以毫秒为单位的差值。

一旦我们计算了elapsedDiffcomputed 属性,我们就可以使用它将moment经过的时间格式化为人类可读的时间格式;也就是说,HH:mm:ss。elapsedTimecomputed 属性让 computed 属性返回一个带有格式化已用时间的字符串。moment.utc()方法是一个函数,它以 UTC 为单位获取时间跨度,然后返回一个moment对象,我们可以在其中调用format()方法来计算时间。

现在我们已经定义了所有的反应和计算属性,我们可以定义我们的方法,这样我们就可以将幻灯片重新排列到正确的图片中。

重新排列幻灯片

我们可以通过编写以下代码来为SliderPuzzle.vue组件添加所需的methods

<script>
...
export default {
  ...
  methods: {
    swap(index) {
      if (!this.timer) {
        return;
      }
      if (this.indexesToSwap.length < 2) {
        this.indexesToSwap.push(index);
      }
      if (this.indexesToSwap.length === 2) {
...
      this.resetTime();
      clearInterval(this.timer);
    },
    resetTime() {
      this.startDateTime = new Date();
      this.currentDateTime = new Date();
    },
    recordSpeedRecords() {
      let records = JSON.parse(localStorage.getItem(
        "records")) || [];
...
      localStorage.setItem("records", stringifiedRecords);
    },
  },
};
</script>

该逻辑在methods属性中定义。我们有swap()方法让我们交换切割的图像幻灯片。start()方法允许我们将反应性属性重置为其初始状态,洗牌剪切的照片幻灯片,然后启动计时器计算经过的时间。我们还检查玩家是否在每次运行计时器代码时都赢了。stop()方法让我们停止计时器。resetTime()方法允许我们将startDateTimecurrentDateTime重置为其当前日期时间。recordSpeedRecords()方法允许我们记录玩家在前 10 名时赢得比赛的时间。

我们从定义swap()方法交换幻灯片的逻辑开始。它需要一个参数,它是我们要交换的幻灯片之一的索引。当播放器单击幻灯片时,调用此方法。通过这种方式,我们将要与另一项交换的其中一项的索引添加到indexesToSwap计算属性中。因此,如果玩家点击两张幻灯片,那么它们的位置将相互交换。

swap()方法主体检查indexesToSwap反应性属性内部是否少于两个幻灯片索引。如果少于两个,那么我们调用push将幻灯片附加到indexesToSwap数组中。接下来,如果indexesToSwapreactive 属性数组中有索引,那么我们进行交换。

为了进行交换,我们从[T0]被动属性中解构索引。然后,我们再次使用解构分配来执行交换:

[this.shuffledPuzzleArray[index1], this.shuffledPuzzleArray[index2]] = [this.shuffledPuzzleArray[index2], this.shuffledPuzzleArray[index1]];

要交换数组中的项目,我们只需将一个[T0]为[T1]的项目分配给[T2]的项目。然后,将原来在shuffledPuzzleArrayindex1中的项目以同样的方式放入shuffledPuzzleArrayindex2插槽中。最后,我们要确保清空[T7]数组,以便玩家可以交换另一对幻灯片。由于shuffledPuzzleArray是一个反应性属性,因此在模板中使用v-for指令更新时,它会自动呈现在模板中。

start()方法让我们启动计时器,计算从点击开始按钮开始游戏到当前日期和时间,直到游戏结束或用户点击退出按钮所经过的时间。首先,该方法通过将这些值设置为当前日期时间来重置startDateTimecurrentDateTime反应性属性,我们通过实例化Date构造函数获得这些值。然后,我们通过制作correctPuzzleArray的副本来洗牌幻灯片,然后像前面一样调用 sort 对correctPuzzle数组的副本进行排序。此外,我们将[T6]属性设置为空数组,以清除存在的所有项,以便重新开始。

完成所有重置后,我们可以调用setInterval启动计时器。这将使用当前日期和时间更新currentDateTime反应性属性,以便我们可以计算elapsedDiffelapsedTime计算的属性。接下来,我们检查isWinning反应性属性以检查它是否为真。如果是,那么我们调用this.recordSpeedRecords方法记录玩家获胜后的最快时间。

如果玩家赢了,如isWinningtrue所示,我们也可以调用stop()方法停止计时器。stop()方法只是调用resetTime()方法来重置所有时间。然后,它调用clearInterval清除计时器。

要显示滑块拼图,我们可以添加template标记:

<template>
  <div>
    <h1>Swap the Images to Win</h1>
    <button @click="start" id="start-button">Start 
      Game</button>
    <button @click="stop" id="quit-button">Quit</button>
    <p>Elapsed Time: {{ elapsedTime }}</p>
    <p v-if="isWinning">You win</p>
    <div class="row">
      <div
        class="column"
        v-for="(s, index) of shuffledPuzzleArray"
        :key="s"
        @click="swap(index)"
      >
        <img :src="require(`img/${puzzleId}/${s}`)" 
          />
      </div>
    </div>
  </div>
</template>

然后,我们可以通过编写以下代码来添加所需的样式:

<style scoped>
.row {
  display: flex;
  max-width: 90vw;
  flex-wrap: wrap;
}
.column {
  flex-grow: 1;
  width: 33%;
}
.column img {
  max-width: 100%;
}
</style>

styles标签中,我们有滑块拼图的样式。我们需要滑块拼图,这样我们可以一行显示三张幻灯片,总共三行。这样,我们以 3x3 的网格显示所有幻灯片。row类的属性设置为flex,因此我们可以使用 flexbox 来布置幻灯片。我们还将flex-wrap属性设置为wrap,以便可以将任何溢出的项包装到下一行。max-width设置为90vw,以便滑块拼图网格将保持在屏幕上。

column类的flex-grow属性设置为1,因此它是行中显示的三个项目之一。

在模板中,我们使用[T1]元素显示游戏的[T0]。我们有一个开始游戏按钮,当我们点击按钮启动游戏计时器时,它会调用start()方法。此外,我们还有一个退出按钮,当我们点击stop()方法停止计时器时,它会调用该方法。elapsedTime计算属性与任何其他反应属性一样显示。如果用户赢了,isWinningreactive 属性返回 true,我们将看到‘You Win’消息。

要显示幻灯片,我们只需使用[T1]指令循环所有[T0]反应性属性,并呈现所有幻灯片。当我们点击每张幻灯片时,会调用带有索引的swap()方法。一旦我们在indexesToSwap反应属性中有了两个索引,我们就交换幻灯片。[T4]属性设置为文件名,因为它们是唯一的。为了显示幻灯片图像,我们使用图像的路径调用require,以便显示图像。

由于我们有 flexbox 样式来显示三行和三行中的项目,所有九个图像将自动显示在 3x3 网格中。现在我们有了滑块益智游戏逻辑,我们需要添加的就是在本地存储中记录计时分数的逻辑。

根据时间计算分数

这是通过recordSpeedRecords()方法完成的。通过从本地存储器获取带有记录的本地存储器项来获取记录。然后,我们得到elapsedTimeelapsedDiff反应性属性值,并将它们推送到records数组中。

接下来,我们使用sort()方法对记录进行排序。这一次,我们不是随机对项目进行排序。相反,我们是通过elapsedDiff反应性属性的 timespan(以毫秒为单位)对它们进行排序。我们传入一个带有ab参数的回调,这两个参数分别是前面的数组项和当前数组项,并返回它们之间的差异。这样,如果返回负数或 0,则它们之间的顺序不变。否则,我们将切换顺序。然后,我们使用第一个和最后一个索引调用slice,将其包含在我们分配给sortedRecords常量的返回数组中。slice()方法返回一个数组,其中第一个索引中的项一直包含到最后一个索引,减去1

最后,我们使用JSON.stringify()方法数组进行字符串化,将sortedRecords数组转换为字符串。然后,我们调用localStorage.setItem将项目放入具有'records'键的项目中。

最后,我们必须将App.vue文件的内容更改为以下内容:

<template>
  <div>
    <Puzzles @puzzle-changed="selectedPuzzleId = $event" />
    <Records />
    <SliderPuzzle :puzzleId="selectedPuzzleId" />
  </div>
</template>
<script>
import SliderPuzzle from "./components/SliderPuzzle.vue";
import Puzzles from "./components/Puzzles.vue";
import Records from "./components/Records.vue";
export default {
  name: "App",
  components: {
    SliderPuzzle,
    Puzzles,
    Records,
  },
  data() {
    return {
      selectedPuzzleId: "cut-pink",
    };
  },
};
</script>

我们添加了前面创建的组件,以便在屏幕上渲染它们。selectedPuzzleId有我们默认选择的拼图的 ID。

现在我们已经有了所有的代码,如果还没有,我们可以通过在项目文件夹中运行npm run serve来运行项目。然后,当我们转到 Vue CLI 指示的 URL 时,我们将看到以下内容:

Figure 3.3 – Screenshot of the slider puzzle game

图 3.3–滑块益智游戏的屏幕截图

现在我们已经完成了 web 应用的代码,我们必须找到一种简单的方法来测试它的所有部分。

带玩笑的单元测试

测试是任何应用的重要组成部分。当我们提到测试时,我们通常指的是自动测试。这些是我们可以快速重复运行的测试,以确保代码不被破坏。当任何测试失败时,我们知道我们的代码没有做它以前做的事情。要么我们制造了一个 bug,要么测试已经过时。因为我们可以快速运行它们,所以我们可以编写其中的许多代码,并在构建代码时运行它们。

这比手动测试更可取,手动测试必须由一个人反复执行相同的操作。手工测试对测试人员来说很无聊,容易出错,而且速度很慢。这对任何人来说都不是一次愉快的经历。因此,最好编写尽可能多的自动化测试,以尽量减少手动测试。

如果遵循 Vue CLI 中显示的说明,则添加框架测试代码非常容易,无需进行任何额外工作。单元测试的文件应该自动为我们生成。我们的代码中应该有一个tests/unit文件夹,用于将测试代码与生产代码分开。

Jest是一个 JavaScript 测试框架,我们可以使用它运行单元测试。它为我们提供了一个有用的 API,让我们能够描述我们的测试组并定义我们的测试。此外,我们可以轻松地模拟通常使用的任何外部依赖项,例如计时器、本地存储和状态。为了模拟localStorage依赖关系,我们可以使用jest-localstorage-mock包。我们可以通过运行npm install jest-localstorage-mock –save-dev来安装它。–save-dev标志允许我们将包保存为开发依赖项,以便它只安装在开发环境中,而不安装在其他地方。另外,在package.json文件中,我们将添加一个jest属性作为root属性。为此,我们可以编写以下代码:

"jest": {
"setupFiles": [
"jest-localstorage-mock"
  ]
}

我们在package.json中有这些属性,因此当我们运行测试时,localStorage依赖项将被模拟出来,以便我们可以检查其方法是否已被调用。与其他属性一起,我们的package.json文件应该如下所示:

{
  "name": "vue-example-ch3-slider-puzzle",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "core-js": "^3.6.5",
    "lodash": "^4.17.20",
    "moment": "^2.28.0",
    "vue": "^3.0.0-0"
  },
  "devDependencies": {
...
    "eslint-plugin-vue": "^7.0.0-0",
    "jest-localstorage-mock": "^2.4.3",
    "typescript": "~3.9.3",
    "vue-jest": "^5.0.0-0"
  },
  "jest": {
    "setupFiles": [
      "jest-localstorage-mock"
    ]
  }
}

完成后,我们可以添加测试。

为 Puzzles.vue 组件添加测试

首先,我们必须从tests/unit文件夹中删除现有文件。然后,我们就可以开始编写测试了。我们可以从为Puzzles.vue组件编写测试开始。为此,我们必须创建tests/unit/puzzles.spec.js文件并编写以下代码:

import { mount } from '@vue/test-utils'
import Puzzles from '@/components/Puzzles.vue'
describe('Puzzles.vue', () => {
  it('emit puzzled-changed event when Play button is 
    clicked', () => {
    const wrapper = mount(Puzzles)
    wrapper.find('.play-button button').trigger('click');
    expect(wrapper.emitted()).toHaveProperty('puzzle-
      changed');
  })
  it('emit puzzled-changed event with the puzzle ID when 
    Play button is clicked', () => {
    const wrapper = mount(Puzzles)
    wrapper.find('.play-button button').trigger('click');
    const puzzleChanged = wrapper.emitted('puzzle-
      changed');
    expect(puzzleChanged[0]).toEqual([wrapper.vm.puzzles[0].id]
 );
  })
})

describe函数获取一个字符串,字符串中包含我们测试组的描述。第二个参数是一个包含测试的回调。describe函数创建一个块,将多个相关测试分组在一起。它的主要目的是使测试结果更容易在我们的屏幕上阅读。

it()函数让我们描述我们的测试。它也被称为test()方法。它的第一个参数是字符串形式的测试的[T2]属性。第二个参数是带有测试代码的回调函数。它还需要一个可选的第三个参数,timeout以毫秒为单位,这样我们的测试就不会永远处于运行状态。默认超时为 5 秒。

如果从ittest函数返回promise,Jest 将在测试完成之前等待承诺的解决。如果我们为[T3]或[T4]函数(通常称为[T5])提供参数,Jest 也会等待。如果将done参数添加到ittest回调中,则调用done函数以指示测试已完成。

ittest函数不必位于传入describe的回调函数中。也可称为独立。但是,最好将相关测试与describe一起分组,以便我们更容易阅读结果。

第一个测试测试当点击播放按钮时,会发出puzzle-changed事件。从Puzzles.vue组件可以看出,puzzle-changed事件是通过this.$emit()方法发出的。为了创建我们的测试,我们调用mount来安装我们的组件。它将要测试的组件作为其参数。它还接受第二个参数,该参数包含我们要覆盖的组件选项的对象。在这个测试中,因为我们没有覆盖任何内容,所以我们没有传递任何内容作为第二个参数。

mount()方法返回wrapper对象,这是我们正在测试的组件的wrapper对象。它有一些方便的方法,我们可以用来做测试。在这个测试中,我们调用find()方法来获取带有给定选择器的 HTML 元素。它返回 HTMLDOM 对象,该对象将调用trigger()方法来触发我们在测试中想要的事件。

通过这种方式,我们可以触发诸如键盘和鼠标事件之类的事件来模拟用户交互。因此,以下代码用于使用.play-button button选择器获取元素,然后触发其上的单击事件:

wrapper.find('.play-button button').trigger('click');

测试的最后一行用于检查puzzle-changed事件是否发出。emitted()方法返回具有名称属性的对象。这些是已发出事件的事件名称。toHaveProperty()方法允许我们检查作为参数传入的属性名是否在返回的对象中。它是由expect()方法返回的对象的属性。

在第二个测试中,我们再次安装组件并在同一个元素上触发click事件。然后,我们用事件名调用emitted()方法,这样我们就可以用事件返回的对象获取该事件发出的有效负载。puzzleChanged数组包含作为第一个元素发出的有效负载。然后,为了检查是否发出了[T3]属性,我们在最后一行中进行了检查。wrapper.vm属性包含装入的组件对象。因此,wrapper.vm.puzzles是谜题的Puzzles成分的反应性质。因此,这意味着我们正在检查来自Puzzles组件的谜题反应属性的id属性是否已发出。

增加记录组件的测试

接下来,我们必须为Records组件编写测试。为此,我们必须创建tests/unit/records.spec.js文件并编写以下代码:

import { shallowMount } from '@vue/test-utils'
import 'jest-localstorage-mock';
import Records from '@/components/Records.vue'
describe('Records.vue', () => {
  it('gets records from local storage', () => {
    shallowMount(Records, {})
    expect(localStorage.getItem).       toHaveBeenCalledWith('records');
  })
})

这就是我们使用jest-localstorage-mock包的地方。我们所要做的就是导入包文件;然后,文件中的代码将运行并为我们模拟localStorage依赖关系。在测试中,我们调用shallowMount来挂载我们的Records组件,然后我们可以检查是否使用'records'参数调用了localStorage.getItem。有了jest-localstorage-mocks包,我们可以直接通过localStorage.getItem让它进行检查。toHaveBeenCalledWith()方法允许我们检查调用它的参数。

因为我们在beforeMount()方法中调用了localStorage.getItem()方法,所以这个测试应该通过,因为我们在加载组件时调用了它。

增加了对 SliderPuzzle 组件的测试

最后,我们必须为SliderPuzzle组件编写一些测试。我们将添加tests/unit/sliderPuzzle.spec.js文件并编写以下代码:

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  it('inserts the index of the image to swap when we click 
    on an image', () => {
    const wrapper = mount(SliderPuzzle)
    wrapper.find('#start-button').trigger('click')
...
    expect(firstImage).toBe(newSecondImage);
    expect(secondImage).toBe(newFirstImage);
  })
  ...
  })
  afterEach(() => {
    jest.clearAllMocks();
  });
})

'inserts the index of the image to swap when we click on an image'测试中,我们安装SliderPuzzle组件,然后触发img元素上的click事件。img元素是滑块拼图的第一张幻灯片。应调用swap()方法,以便indexesToSwap反应性属性具有添加的第一个图像的索引。toBeGreaterThan()方法允许我们检查我们期望的返回值是否大于某个数字。

'swaps the image order when 2 images are clicked'测试中,我们再次安装SliderPuzzle组件。然后,我们得到wrapper.vm.shuffledPuzzleArray来获取前面数组中的索引并对其值进行分解。稍后,我们将使用它来比较来自同一数组的值,以查看在单击两个图像后,它们是否已被交换。

接下来,我们使用wrapper.get()方法触发点击幻灯片,以获得图像元素。然后调用trigger()方法触发点击事件。然后,我们检查交换完成后,indexesToSwap反应性属性的长度是否为0。然后,在最后三行中,我们再次从wrapper.vm.shuffledPuzzleArray中获取项目,并比较它们的值。由于条目应该在两张幻灯片之后交换,因此我们有以下代码来检查交换是否实际完成:

expect(firstImage).toBe(newSecondImage);
expect(secondImage).toBe(newFirstImage);

'starts timer when start method is called'测试中,我们再次安装SliderPuzzle组件。这一次,我们调用start()方法,以确保计时器实际上是用setInterval创建的。我们还检查是否使用函数和 1000 毫秒调用了setInterval函数。为了让我们能够轻松地使用计时器测试任何东西,包括测试调用setTimeoutsetInterval的任何东西,我们调用jest.useFakeTimers()来模拟这些函数,以便我们的测试不会干扰其他测试的操作:

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  ...
  it('starts timer when start method is called', () => {
    const wrapper = mount(SliderPuzzle);
    wrapper.vm.start();
    expect(setInterval).toHaveBeenCalledTimes(1);
    expect(setInterval).toHaveBeenLastCalledWith(expect.any(
      Function), 1000);
  })
  ...
  afterEach(() => {
    jest.clearAllMocks();
  });
})

toHaveBeenCalledTimes()方法检查我们传递到expect()方法的函数是否被调用了给定的次数。因为我们称之为jest.useFakeTimers(),``setInterval实际上是真实setInterval函数的间谍,而不是真实版本。我们只能对带有expecttoHaveBeenCalledTimes以及toHaveBeenCalledWith的函数使用间谍。因此,我们的代码将起作用。toHaveBeenLastCalledWith()方法用于检查使用给定类型的参数调用函数 spy 的参数。我们确保第一个参数是函数,第二个参数是 1000 毫秒。

'stops timer when stop method is called'测试中,我们通过安装组件然后调用stop()方法来执行类似的操作。我们确保在调用stop()方法时实际调用了clearInterval

import { mount } from '@vue/test-utils'
import SliderPuzzle from '@/components/SliderPuzzle.vue'
import 'jest-localstorage-mock';
jest.useFakeTimers();
describe('SliderPuzzle.vue', () => {
  ...
  it('stops timer when stop method is called', () => {
    const wrapper = mount(SliderPuzzle);
    wrapper.vm.stop();
    expect(clearInterval).toHaveBeenCalledTimes(1);
  })
  it('shows the elapsed time', () => {
    const wrapper = mount(SliderPuzzle, {
      data() {
        return {
          currentDateTime: new Date(2020, 0, 1, 0, 0, 1),
          startDateTime: new Date(2020, 0, 1, 0, 0, 0),
        }
      }
    });
    expect(wrapper.html()).toContain('00:00:01')
  })
  ...
  afterEach(() => {
    jest.clearAllMocks();
  });
})

接下来,我们添加'records record to local storage'测试。我们再次使用jest-localstorage-mock库来模拟localStorage依赖关系。在本测试中,我们以不同的方式安装了SliderPuzzle组件。第二个参数是包含data()方法的对象。这是我们在组件的options对象中使用的data()方法。我们用传入的内容覆盖组件的原始被动属性值。currentDateTimestartDateTime反应性属性被覆盖,这样我们就可以将日期设置为我们想要的,这样我们就可以用它们进行测试。

然后,我们调用wrapper.vm.recordSpeedRecords()方法来测试是否调用了localStorage.setItem()方法。我们调用挂载组件中的方法。然后,我们创建stringifiedRecordsJSON 字符串,以便我们可以将其与localStrorage.setItem调用的内容进行比较。toHaveBeenCalledWith只适用于localStorage.setItem,因为我们导入了jest-localstorage-mock库,以从实际的localStorage.setItem()方法创建间谍。这允许 Jest 检查是否使用给定参数调用了该方法。

为了测试点击启动按钮时计时器是否启动,我们进行了'starts timer with Start button is clicked'测试。我们只需通过get()方法获取启动按钮的 ID,并触发其上的click事件。然后,我们检查setInterval函数是否被调用。与localStorage一样,我们使用jest.useFakeTimers()方法模拟setInterval函数,从实际的setInterval函数创建间谍。这让我们检查它是否被调用。

类似地,我们有'stops timer with Quit button is clicked'测试来检查如果点击退出按钮,是否调用了clearInterval函数。

最后,我们进行了'shows the elapsed time'测试,以安装具有currentDateTimestartDateTime反应特性不同值的组件。它们被设置为我们想要的值,并且它们将保持测试中的状态。然后,为了检查elapsedTime计算属性是否正确显示,我们调用wrapper.html()方法返回包装组件中呈现的 HTML,并检查它是否包含我们正在查找的已用时间字符串。

为了在每次测试后清理模拟,以便我们在每次测试后重新开始,我们调用jest.clearAllMocks()方法在每次测试后清除所有模拟。afterEach函数接受在每次测试完成后运行的回调。

运行所有测试

为了运行测试,我们运行npm run test:unit。通过这样做,我们将看到如下内容:

Figure 3.4 – Results of our unit tests

图 3.4–我们的单元测试结果

由于所有的测试都通过了,我们项目中的代码正在做我们期望的事情。运行所有测试只需要大约 4 秒钟,这比手动测试代码快得多。

总结

在本章中,我们通过定义组件中的计算属性来深入了解组件。此外,我们还为组件添加了测试,以便可以单独测试组件的各个部分。通过 Vue CLI,我们可以在应用中轻松添加测试文件和依赖项。

在我们的组件内部,我们可以通过this.$emit()方法发出传播到父组件的事件。它接受了一个带有事件名称的字符串。其他参数是我们希望从父组件传递到子组件的有效负载。

为了将单元测试添加到 VUE3 应用并运行测试,我们使用了 Jest 测试框架。VUE3 在 Jest 中添加了自己的特定 API,以便我们可以使用它测试 VUE3 组件。为了测试组件,我们使用mountshallowMount功能安装了组件。mount函数允许我们装载组件本身,包括嵌套组件。shallowMount功能只安装组件本身,不安装子组件。它们都为我们的组件返回一个wrapper,以便我们可以使用它与组件交互来进行测试。

我们应该确保我们的测试是独立运行的。这就是我们嘲笑外部依赖关系的原因。我们不希望运行任何需要测试和项目代码之外的任何内容才可用的代码。此外,如果需要,我们必须确保清除测试中的任何依赖项。如果有任何模拟,我们必须清理它们,这样它们就不会进行另一次测试。否则,我们可能会有依赖于其他测试的测试,这使得故障排除测试非常困难。

在下一章中,我们将介绍如何创建一个照片库应用,通过将要保存的数据发送到后端 API 来保存数据。我们将介绍 Vue 路由的使用,以便我们可以导航到不同的页面。