cozy-iiif 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection } from '@iiif/presentation-3';
1
+ import { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range, AnnotationPage } from '@iiif/presentation-3';
2
2
  export type CozyParseResult = {
3
3
  type: 'collection';
4
4
  url: string;
@@ -41,15 +41,26 @@ export interface CozyManifest {
41
41
  readonly source: Manifest;
42
42
  readonly id: string;
43
43
  readonly canvases: CozyCanvas[];
44
+ readonly structure: CozyRange[];
44
45
  getLabel(locale?: string): string | undefined;
46
+ getTableOfContents(): CozyTOCNode[];
45
47
  getMetadata(locale?: string): CozyMetadata[];
46
48
  }
49
+ export interface CozyRange {
50
+ readonly source: Range;
51
+ readonly id: string;
52
+ readonly items: (CozyCanvas | CozyRange)[];
53
+ readonly canvases: CozyCanvas[];
54
+ readonly ranges: CozyRange[];
55
+ getLabel(locale?: string): string | undefined;
56
+ }
47
57
  export interface CozyCanvas {
48
58
  readonly source: Canvas;
49
59
  readonly id: string;
50
60
  readonly width: number;
51
61
  readonly height: number;
52
62
  readonly images: CozyImageResource[];
63
+ readonly annotations: AnnotationPage[];
53
64
  getLabel(locale?: string): string;
54
65
  getMetadata(locale?: string): CozyMetadata[];
55
66
  getThumbnailURL(minSize?: number): string;
@@ -58,6 +69,14 @@ export interface CozyMetadata {
58
69
  readonly label: string;
59
70
  readonly value: string;
60
71
  }
72
+ export interface CozyTOCNode {
73
+ readonly id: string;
74
+ readonly type: 'range' | 'canvas';
75
+ getLabel(locale?: string): string | undefined;
76
+ children: CozyTOCNode[];
77
+ parent?: CozyTOCNode;
78
+ level: number;
79
+ }
61
80
  export type CozyImageResource = StaticImageResource | ImageServiceResource;
62
81
  export type ImageServiceResource = DynamicImageServiceResource | Level0ImageServiceResource;
63
82
  interface BaseImageResource {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-iiif",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A developer-friendly collection of abstractions and utilities built on top of @iiif/presentation-3 and @iiif/parser",
5
5
  "license": "MIT",
6
6
  "author": "Rainer Simon",
@@ -27,13 +27,14 @@
27
27
  "./level-0": "./dist/level-0/index.js"
28
28
  },
29
29
  "devDependencies": {
30
- "vite": "^6.2.2",
30
+ "vite": "^6.2.5",
31
31
  "vite-plugin-dts": "^4.5.3",
32
- "vitest": "^3.0.8"
32
+ "vitest": "^3.1.1"
33
33
  },
34
34
  "dependencies": {
35
35
  "@iiif/parser": "^2.1.7",
36
36
  "@iiif/presentation-3": "^2.2.3",
37
- "p-throttle": "^7.0.0"
37
+ "p-throttle": "^7.0.0",
38
+ "uuid": "^11.1.0"
38
39
  }
39
40
  }
package/src/Cozy.ts CHANGED
@@ -1,11 +1,13 @@
1
- import type { Canvas, Collection, Manifest } from '@iiif/presentation-3';
1
+ import type { Canvas, Collection, Manifest, Range } from '@iiif/presentation-3';
2
2
  import { convertPresentation2 } from '@iiif/parser/presentation-2';
3
3
  import { Traverse } from '@iiif/parser';
4
+ import * as Helpers from './helpers';
4
5
  import {
5
6
  getImages,
6
7
  getLabel,
7
8
  getMetadata,
8
9
  getPropertyValue,
10
+ getTableOfContents,
9
11
  getThumbnailURL,
10
12
  normalizeServiceUrl,
11
13
  parseImageService
@@ -16,126 +18,125 @@ import type {
16
18
  CozyCollectionItem,
17
19
  CozyManifest,
18
20
  CozyParseResult,
21
+ CozyRange,
19
22
  ImageServiceResource
20
23
  } from './types';
21
24
 
22
- export const Cozy = {
23
-
24
- parseURL: async (input: string): Promise<CozyParseResult> => {
25
- try {
26
- new URL(input);
27
- } catch {
28
- return {
29
- type: 'error',
30
- code: 'INVALID_URL',
31
- message: 'The provided input is not a valid URL'
32
- };
33
- }
25
+ const parseURL = async (input: string): Promise<CozyParseResult> => {
26
+ try {
27
+ new URL(input);
28
+ } catch {
29
+ return {
30
+ type: 'error',
31
+ code: 'INVALID_URL',
32
+ message: 'The provided input is not a valid URL'
33
+ };
34
+ }
34
35
 
35
- let response: Response;
36
+ let response: Response;
36
37
 
37
- try {
38
- response = await fetch(input);
39
- if (!response.ok) {
40
- return {
41
- type: 'error',
42
- code: 'INVALID_HTTP_RESPONSE',
43
- message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
44
- }
45
- }
46
- } catch (error) {
38
+ try {
39
+ response = await fetch(input);
40
+ if (!response.ok) {
47
41
  return {
48
42
  type: 'error',
49
- code: 'FETCH_ERROR',
50
- message: error instanceof Error ? error.message : 'Failed to fetch resource'
51
- };
43
+ code: 'INVALID_HTTP_RESPONSE',
44
+ message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
45
+ }
52
46
  }
47
+ } catch (error) {
48
+ return {
49
+ type: 'error',
50
+ code: 'FETCH_ERROR',
51
+ message: error instanceof Error ? error.message : 'Failed to fetch resource'
52
+ };
53
+ }
53
54
 
54
- const contentType = response.headers.get('content-type');
55
-
56
- if (contentType?.startsWith('image/')) {
57
- return {
58
- type: 'plain-image',
59
- url: input
60
- };
61
- }
55
+ const contentType = response.headers.get('content-type');
56
+
57
+ if (contentType?.startsWith('image/')) {
58
+ return {
59
+ type: 'plain-image',
60
+ url: input
61
+ };
62
+ }
62
63
 
63
- if (contentType?.includes('text/html')) {
64
- return {
65
- type: 'webpage',
66
- url: input
67
- };
68
- }
64
+ if (contentType?.includes('text/html')) {
65
+ return {
66
+ type: 'webpage',
67
+ url: input
68
+ };
69
+ }
69
70
 
70
- try {
71
- const json = await response.json();
72
-
73
- const context = Array.isArray(json['@context'])
74
- ? json['@context'].find(str => str.includes('iiif.io/api/'))
75
- : json['@context'];
76
-
77
- if (!context) {
78
- return {
79
- type: 'error',
80
- code: 'INVALID_MANIFEST',
81
- message: 'Missing @context'
82
- }
83
- };
84
-
85
- const id = getPropertyValue<string>(json, 'id');
86
-
87
- if (!id) {
88
- return {
89
- type: 'error',
90
- code: 'INVALID_MANIFEST',
91
- message: 'Missing id property'
92
- }
93
- }
71
+ try {
72
+ const json = await response.json();
73
+ return parse(json, input);
74
+ } catch {
75
+ return {
76
+ type: 'error',
77
+ code: 'UNSUPPORTED_FORMAT',
78
+ message: 'Could not parse resource'
79
+ };
80
+ }
81
+ }
94
82
 
95
- if (context.includes('presentation/2') || context.includes('presentation/3')) {
96
- const majorVersion = context.includes('presentation/2') ? 2 : 3;
83
+ const parse = (json: any, url?: string): CozyParseResult => {
84
+ const context = Array.isArray(json['@context'])
85
+ ? json['@context'].find(str => str.includes('iiif.io/api/'))
86
+ : json['@context'];
97
87
 
98
- const type = getPropertyValue(json, 'type');
88
+ if (!context) {
89
+ return {
90
+ type: 'error',
91
+ code: 'INVALID_MANIFEST',
92
+ message: 'Missing @context'
93
+ }
94
+ };
99
95
 
100
- return type.includes('Collection') ? {
101
- type: 'collection',
102
- url: input,
103
- resource: parseCollectionResource(json, majorVersion)
104
- } : {
105
- type: 'manifest',
106
- url: input,
107
- resource: parseManifestResource(json, majorVersion)
108
- };
109
- }
110
-
111
- if (context.includes('image/2') || context.includes('image/3')) {
112
- const resource = parseImageResource(json);
113
- return resource ? {
114
- type: 'iiif-image',
115
- url: input,
116
- resource
117
- } : {
118
- type: 'error',
119
- code: 'INVALID_MANIFEST',
120
- message: 'Invalid image service definition'
121
- }
122
- }
96
+ const id = getPropertyValue<string>(json, 'id');
123
97
 
124
- return {
125
- type: 'error',
126
- code: 'INVALID_MANIFEST',
127
- message: 'JSON resource is not a recognized IIIF format'
128
- };
129
- } catch {
130
- return {
131
- type: 'error',
132
- code: 'UNSUPPORTED_FORMAT',
133
- message: 'Could not parse resource'
134
- };
98
+ if (!id) {
99
+ return {
100
+ type: 'error',
101
+ code: 'INVALID_MANIFEST',
102
+ message: 'Missing id property'
135
103
  }
104
+ }
105
+
106
+ if (context.includes('presentation/2') || context.includes('presentation/3')) {
107
+ const majorVersion = context.includes('presentation/2') ? 2 : 3;
108
+
109
+ const type = getPropertyValue(json, 'type');
136
110
 
111
+ return type.includes('Collection') ? {
112
+ type: 'collection',
113
+ url: url || id,
114
+ resource: parseCollectionResource(json, majorVersion)
115
+ } : {
116
+ type: 'manifest',
117
+ url: url || id,
118
+ resource: parseManifestResource(json, majorVersion)
119
+ };
120
+ }
121
+
122
+ if (context.includes('image/2') || context.includes('image/3')) {
123
+ const resource = parseImageResource(json);
124
+ return resource ? {
125
+ type: 'iiif-image',
126
+ url: url || id,
127
+ resource
128
+ } : {
129
+ type: 'error',
130
+ code: 'INVALID_MANIFEST',
131
+ message: 'Invalid image service definition'
132
+ }
137
133
  }
138
134
 
135
+ return {
136
+ type: 'error',
137
+ code: 'INVALID_MANIFEST',
138
+ message: 'JSON resource is not a recognized IIIF format'
139
+ };
139
140
  }
140
141
 
141
142
  const parseCollectionResource = (resource: any, majorVersion: number): CozyCollection => {
@@ -174,15 +175,17 @@ const parseCollectionResource = (resource: any, majorVersion: number): CozyColle
174
175
  const parseManifestResource = (resource: any, majorVersion: number): CozyManifest => {
175
176
 
176
177
  const parseV3 = (manifest: Manifest) => {
177
- const canvases: Canvas[] = [];
178
+ const sourceCanvases: Canvas[] = [];
179
+ const sourceRanges: Range[] = [];
178
180
 
179
181
  const modelBuilder = new Traverse({
180
- canvas: [canvas => { if (canvas.items) canvases.push(canvas) }]
182
+ canvas: [canvas => { if (canvas.items) sourceCanvases.push(canvas) }],
183
+ range: [range => { if (range.type === 'Range') sourceRanges.push(range) }]
181
184
  });
182
185
 
183
186
  modelBuilder.traverseManifest(manifest);
184
187
 
185
- return canvases.map((c: Canvas) => {
188
+ const canvases = sourceCanvases.map((c: Canvas) => {
186
189
  const images = getImages(c);
187
190
  return {
188
191
  source: c,
@@ -190,24 +193,55 @@ const parseManifestResource = (resource: any, majorVersion: number): CozyManifes
190
193
  width: c.width,
191
194
  height: c.height,
192
195
  images,
196
+ annotations: (c.annotations || []),
193
197
  getLabel: getLabel(c),
194
198
  getMetadata: getMetadata(c),
195
199
  getThumbnailURL: getThumbnailURL(c, images)
196
200
  } as CozyCanvas;
197
201
  });
202
+
203
+ const toRange = (source: Range): CozyRange => {
204
+ const items = source.items || [];
205
+
206
+ const nestedCanvases: CozyCanvas[] = items
207
+ .filter((item: any) => item.type === 'Canvas')
208
+ .map((item: any) => canvases.find(c => c.id === item.id)!)
209
+ .filter(Boolean);
210
+
211
+ const nestedRanges = items
212
+ .filter((item: any) => item.type === 'Range')
213
+ .map((item: any) => toRange(item));
214
+
215
+ const nestedItems = [...nestedCanvases, ...nestedRanges];
216
+
217
+ return {
218
+ source,
219
+ id: source.id,
220
+ // Maintain original order
221
+ items: items.map((i: any) => nestedItems.find(cozy => cozy.id === i.id)),
222
+ canvases: nestedCanvases,
223
+ ranges: nestedRanges,
224
+ getLabel: getLabel(source)
225
+ } as CozyRange;
226
+ }
227
+
228
+ const ranges = sourceRanges.map((source: Range) => toRange(source));
229
+ return { canvases, ranges };
198
230
  }
199
231
 
200
232
  const v3: Manifest = majorVersion === 2 ? convertPresentation2(resource) : resource;
201
233
 
202
- const canvases = parseV3(v3);
234
+ const { canvases, ranges } = parseV3(v3);
203
235
 
204
236
  return {
205
237
  source: v3,
206
238
  id: v3.id,
207
239
  majorVersion,
208
240
  canvases,
241
+ structure: ranges,
209
242
  getLabel: getLabel(v3),
210
- getMetadata: getMetadata(v3)
243
+ getMetadata: getMetadata(v3),
244
+ getTableOfContents: getTableOfContents(ranges)
211
245
  }
212
246
  }
213
247
 
@@ -225,4 +259,6 @@ const parseImageResource = (resource: any) => {
225
259
  serviceUrl: normalizeServiceUrl(getPropertyValue<string>(resource, 'id'))
226
260
  } as ImageServiceResource;
227
261
  }
228
- }
262
+ }
263
+
264
+ export const Cozy = { parse, parseURL, Helpers };
package/src/core/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './canvas';
2
2
  export * from './image-service';
3
+ export * from './manifest';
3
4
  export * from './resource';
@@ -0,0 +1,38 @@
1
+ import type { CozyRange, CozyTOCNode } from '../types';
2
+
3
+ export const getTableOfContents = (ranges: CozyRange[]) => () => {
4
+
5
+ const buildTree = (range: CozyRange, parent: CozyTOCNode | undefined, level: number = 0): CozyTOCNode => {
6
+ const node: CozyTOCNode = {
7
+ id: range.id,
8
+ type: 'range',
9
+ getLabel: range.getLabel,
10
+ children: [],
11
+ parent,
12
+ level
13
+ };
14
+
15
+ if (range.items && range.items.length > 0) {
16
+ range.items.forEach(item => {
17
+ if (item.source.type === 'Range') {
18
+ const childNode = buildTree(item as CozyRange, node, level + 1);
19
+ node.children.push(childNode);
20
+ } else {
21
+ node.children.push({
22
+ id: item.id,
23
+ type: 'canvas',
24
+ getLabel: item.getLabel,
25
+ children: [],
26
+ parent: node,
27
+ level: level + 1
28
+ });
29
+ }
30
+ });
31
+ }
32
+
33
+ return node;
34
+ };
35
+
36
+ const topRanges = ranges.filter(range => range.source.behavior?.includes('top'));
37
+ return topRanges.map(range => buildTree(range, undefined));
38
+ }
@@ -0,0 +1,115 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import type { Annotation } from '@iiif/presentation-3';
3
+ import type { CozyCanvas, CozyManifest } from '../types';
4
+
5
+ // Helper to escape special characters in strings used in RegExp
6
+ const escapeRegExp = (str: string) =>
7
+ str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+
9
+ const getAnnotationPageId = (canvas: CozyCanvas, namespace?: string) => {
10
+ if (namespace) {
11
+ // Use naming convention `{canvas.id}/namespace/page/p{idx}`. This means we need
12
+ // to find the highest index currently in use. (Index starts with 1.)
13
+ const pages = canvas.annotations;
14
+ if (pages.length > 0) {
15
+ const pattern = new RegExp(`${escapeRegExp(canvas.id)}/${escapeRegExp(namespace)}/page/p(\\d+)$`);
16
+
17
+ const highestIdx = pages.reduce<number>((highest, page) => {
18
+ const match = page.id.match(pattern);
19
+ if (match && match[1]) {
20
+ const thisIndex = parseInt(match[1]);
21
+ return Math.max(highest, thisIndex);
22
+ } else {
23
+ return highest;
24
+ }
25
+ }, 1);
26
+
27
+ return `${canvas.id}/${namespace}/annotations/page/p${highestIdx}`;
28
+ } else {
29
+ return `${canvas.id}/${namespace}/annotations/page/p1`;
30
+ }
31
+ } else {
32
+ // Use UUIDs as a fallback naming convention
33
+ return `${canvas.id}/annotations/page/${uuidv4()}`;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Will blindy attach the annotations to this canvas.
39
+ */
40
+ const importAnnotationsToCanvas = (canvas: CozyCanvas, annotations: Annotation[], namespace?: string) => {
41
+ const page = {
42
+ id: getAnnotationPageId(canvas, namespace),
43
+ type: 'AnnotationPage',
44
+ items: annotations
45
+ }
46
+
47
+ return {
48
+ source: {
49
+ ...canvas.source,
50
+ annotations: [...canvas.annotations, page]
51
+ },
52
+ id: canvas.id,
53
+ width: canvas.width,
54
+ height: canvas.height,
55
+ images: [...canvas.images],
56
+ annotations: [...canvas.annotations, page],
57
+ getLabel: canvas.getLabel,
58
+ getMetadata: canvas.getMetadata,
59
+ getThumbnailURL: canvas.getThumbnailURL
60
+ } as CozyCanvas;
61
+ }
62
+
63
+ /**
64
+ * Will use 'source' information from the annotation targets to associate annotations with the right
65
+ * canvases.
66
+ */
67
+ const importAnnotationsToManifest = (manifest: CozyManifest, annotations: Annotation[], namespace?: string) => {
68
+ const getSource = (annotation: Annotation) => {
69
+ const target = annotation.target;
70
+ if (!target) return;
71
+
72
+ if (typeof target === 'string')
73
+ return target.substring(0, target.indexOf('#'));
74
+ else
75
+ return (target as any).source;
76
+ }
77
+
78
+ const bySource = annotations.reduce<Record<string, Annotation[]>>((acc, annotation) => {
79
+ const source = getSource(annotation);
80
+ if (!source) return acc;
81
+
82
+ if (!acc[source]) acc[source] = [];
83
+ acc[source].push(annotation);
84
+
85
+ return acc;
86
+ }, {});
87
+
88
+ const canvases = manifest.canvases.map(canvas => {
89
+ const toImport = bySource[canvas.id] || [];
90
+ return toImport.length > 0 ? importAnnotationsToCanvas(canvas, toImport, namespace) : canvas;
91
+ });
92
+
93
+ return {
94
+ source: {
95
+ ...manifest.source,
96
+ items: canvases.map(c => c.source)
97
+ },
98
+ id: manifest.id,
99
+ majorVersion: manifest.majorVersion,
100
+ canvases,
101
+ structure: manifest.structure,
102
+ getLabel: manifest.getLabel,
103
+ getMetadata: manifest.getMetadata,
104
+ getTableOfContents: manifest.getTableOfContents
105
+ }
106
+ }
107
+
108
+ export const importAnnotations = <T extends CozyManifest | CozyCanvas>(
109
+ resource: T,
110
+ annotations: Annotation[],
111
+ namespace?: string
112
+ ): T extends CozyCanvas ? CozyCanvas : CozyManifest =>
113
+ resource.source.type === 'Canvas'
114
+ ? importAnnotationsToCanvas(resource as CozyCanvas, annotations, namespace) as T extends CozyCanvas ? CozyCanvas : CozyManifest
115
+ : importAnnotationsToManifest(resource as CozyManifest, annotations, namespace) as T extends CozyCanvas ? CozyCanvas : CozyManifest;
@@ -0,0 +1 @@
1
+ export * from './import-annotations';
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection } from '@iiif/presentation-3';
1
+ import type { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range, Annotation, AnnotationPage } from '@iiif/presentation-3';
2
2
 
3
3
  export type CozyParseResult =
4
4
  | { type: 'collection', url: string, resource: CozyCollection }
@@ -50,12 +50,32 @@ export interface CozyManifest {
50
50
 
51
51
  readonly canvases: CozyCanvas[];
52
52
 
53
+ readonly structure: CozyRange[];
54
+
53
55
  getLabel(locale?: string): string | undefined;
54
56
 
57
+ getTableOfContents(): CozyTOCNode[];
58
+
55
59
  getMetadata(locale?: string): CozyMetadata[];
56
60
 
57
61
  }
58
62
 
63
+ export interface CozyRange {
64
+
65
+ readonly source: Range;
66
+
67
+ readonly id: string;
68
+
69
+ readonly items: (CozyCanvas | CozyRange)[];
70
+
71
+ readonly canvases: CozyCanvas[];
72
+
73
+ readonly ranges: CozyRange[];
74
+
75
+ getLabel(locale?: string): string | undefined;
76
+
77
+ }
78
+
59
79
  export interface CozyCanvas {
60
80
 
61
81
  readonly source: Canvas;
@@ -68,6 +88,8 @@ export interface CozyCanvas {
68
88
 
69
89
  readonly images: CozyImageResource[];
70
90
 
91
+ readonly annotations: AnnotationPage[];
92
+
71
93
  getLabel(locale?: string): string;
72
94
 
73
95
  getMetadata(locale?: string): CozyMetadata[];
@@ -84,6 +106,22 @@ export interface CozyMetadata {
84
106
 
85
107
  }
86
108
 
109
+ export interface CozyTOCNode {
110
+
111
+ readonly id: string;
112
+
113
+ readonly type: 'range' | 'canvas';
114
+
115
+ getLabel(locale?: string): string | undefined;
116
+
117
+ children: CozyTOCNode[];
118
+
119
+ parent?: CozyTOCNode;
120
+
121
+ level: number;
122
+
123
+ }
124
+
87
125
  export type CozyImageResource =
88
126
  | StaticImageResource
89
127
  | ImageServiceResource;
package/test/Cozy.test.ts CHANGED
@@ -1,13 +1,27 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { Cozy } from '../src';
2
+ import { Cozy, CozyManifest } from '../src';
3
3
 
4
- import { COLLECTION } from './fixtures';
4
+ import { COLLECTION, WITH_STRUCTURES } from './fixtures';
5
5
 
6
6
  describe('Cozy', () => {
7
7
 
8
8
  it('should parse collection manifests correctly', async () => {
9
- const result = await Cozy.parseURL(COLLECTION)
9
+ const result = await Cozy.parseURL(COLLECTION);
10
10
  expect(result.type).toBe('collection');
11
- })
11
+ });
12
+
13
+ it('should parse strctures in presentation manifests', async () => {
14
+ const result = await Cozy.parseURL(WITH_STRUCTURES);
15
+ expect(result.type).toBe('manifest');
16
+ expect('resource' in result).toBeTruthy();
17
+
18
+ const manifest = (result as any).resource as CozyManifest;
19
+ expect(manifest.structure.length > 0).toBeTruthy();
20
+
21
+ const tableOfContents = manifest.getTableOfContents();
22
+ expect(tableOfContents.length).toBe(1);
23
+ expect(tableOfContents[0].children.length).toBe(14);
24
+ });
12
25
 
13
26
  });
27
+