You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/07/06 08:13:08 UTC
(texera) branch main updated: test(workflow-core): add unit tests for PhysicalFileNode, Schema, and VFSURIFactory (#6127)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 233c4deb2b test(workflow-core): add unit tests for PhysicalFileNode, Schema, and VFSURIFactory (#6127)
233c4deb2b is described below
commit 233c4deb2bd646a5e52eea042384caa28400dbf6
Author: Xinyuan Lin <xi...@uci.edu>
AuthorDate: Mon Jul 6 00:35:15 2026 -0700
test(workflow-core): add unit tests for PhysicalFileNode, Schema, and VFSURIFactory (#6127)
### What changes were proposed in this PR?
Adds unit-test coverage for three value/utility classes in
`common/workflow-core` that Codecov reports as having uncovered lines:
- **`PhysicalFileNodeSpec`** (new) — covers the `PhysicalFileNode` value
class: constructor/getters, `isFile`/`isDirectory`, `addChildNode`
success and the direct-subpath guard failure, `equals`/`hashCode` (which
deliberately excludes `size`), and the recursive
`getAllFileRelativePaths` traversal.
- **`SchemaSpec`** (extended) — adds `hashCode`/`equals` cases
(including non-`Schema` operands), both duplicate-name rejection paths
(`add(Iterable)` and `add(Attribute)`), and a Jackson JSON round-trip.
- **`VFSURIFactorySpec`** (extended) — adds the missing-value guard
where a required key is the final path segment.
No production code is changed; this is test-only.
### Any related issues, documentation, discussions?
Coverage gaps identified from the Codecov report for `apache/texera`.
### How was this PR tested?
New/extended ScalaTest specs, run locally:
```
sbt "WorkflowCore/testOnly *PhysicalFileNodeSpec *SchemaSpec *VFSURIFactorySpec"
```
All pass. `scalafmt` and `scalafixAll --check` are clean.
### Was this PR authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 4.8 [1M context])
---
.../amber/core/storage/VFSURIFactorySpec.scala | 7 ++
.../util/dataset/PhysicalFileNodeSpec.scala | 110 +++++++++++++++++++++
.../texera/amber/core/tuple/SchemaSpec.scala | 48 +++++++++
3 files changed, 165 insertions(+)
diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
index 0b8ae4a19c..f08bd292d2 100644
--- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
+++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/VFSURIFactorySpec.scala
@@ -113,4 +113,11 @@ class VFSURIFactorySpec extends AnyFlatSpec {
VFSURIFactory.decodeURI(new URI("vfs:///wid/1/eid/2/notarealresource"))
}
}
+
+ it should "reject a URI where a required key is the final segment with no value" in {
+ // "wid" is present but is the last segment (index + 1 >= segments.length)
+ assertThrows[IllegalArgumentException] {
+ VFSURIFactory.decodeURI(new URI("vfs:///eid/2/wid"))
+ }
+ }
}
diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/util/dataset/PhysicalFileNodeSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/util/dataset/PhysicalFileNodeSpec.scala
new file mode 100644
index 0000000000..d12431ae40
--- /dev/null
+++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/storage/util/dataset/PhysicalFileNodeSpec.scala
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.texera.amber.core.storage.util.dataset
+
+import org.scalatest.flatspec.AnyFlatSpec
+
+import java.nio.file.{Files, Path}
+import scala.jdk.CollectionConverters._
+import scala.util.Using
+
+class PhysicalFileNodeSpec extends AnyFlatSpec {
+
+ private def withRepo(body: Path => Unit): Unit = {
+ val repo = Files.createTempDirectory("pfn-")
+ try body(repo)
+ finally {
+ // Files.walk returns a Stream holding a directory handle; close it so temp
+ // cleanup does not intermittently fail (notably on Windows) on a leaked handle.
+ Using.resource(Files.walk(repo)) { stream =>
+ stream
+ .sorted(java.util.Comparator.reverseOrder[Path]())
+ .forEach(p => Files.deleteIfExists(p))
+ }
+ }
+ }
+
+ "PhysicalFileNode" should "expose its paths, size, and empty children" in withRepo { repo =>
+ val file = Files.createFile(repo.resolve("a.txt"))
+ val node = new PhysicalFileNode(repo, file, 42L)
+ assert(node.getAbsolutePath == file)
+ assert(node.getRelativePath == repo.relativize(file))
+ assert(node.getRelativePath.toString == "a.txt")
+ assert(node.getSize == 42L)
+ assert(node.getChildren.isEmpty)
+ }
+
+ it should "report isFile / isDirectory from the filesystem" in withRepo { repo =>
+ val file = Files.createFile(repo.resolve("a.txt"))
+ val dir = Files.createDirectory(repo.resolve("sub"))
+ val fileNode = new PhysicalFileNode(repo, file, 0L)
+ val dirNode = new PhysicalFileNode(repo, dir, 0L)
+ assert(fileNode.isFile && !fileNode.isDirectory)
+ assert(dirNode.isDirectory && !dirNode.isFile)
+ val missing = new PhysicalFileNode(repo, repo.resolve("nope"), 0L)
+ assert(!missing.isFile && !missing.isDirectory)
+ }
+
+ it should "accept a direct child and reject a non-direct one" in withRepo { repo =>
+ val sub = Files.createDirectory(repo.resolve("sub"))
+ val parent = new PhysicalFileNode(repo, sub, 0L)
+ val child = new PhysicalFileNode(repo, sub.resolve("child.txt"), 0L)
+ parent.addChildNode(child)
+ assert(parent.getChildren.size == 1)
+ assert(parent.getChildren.contains(child))
+
+ val parentOfRepo = new PhysicalFileNode(repo, repo, 0L)
+ val deep = new PhysicalFileNode(repo, sub.resolve("deep.txt"), 0L)
+ val ex = intercept[IllegalArgumentException](parentOfRepo.addChildNode(deep))
+ assert(ex.getMessage == "Child node is not a direct subpath of the parent node")
+ }
+
+ it should "define equals and hashCode over the absolute path and children only" in withRepo {
+ repo =>
+ val file = Files.createFile(repo.resolve("a.txt"))
+ val other = Files.createFile(repo.resolve("b.txt"))
+ val node = new PhysicalFileNode(repo, file, 1L)
+ assert(node.equals(node)) // identity
+ assert(!node.equals(null))
+ assert(!node.equals("not a node"))
+ // size is excluded from equals/hashCode
+ val sameByPath = new PhysicalFileNode(repo, file, 999L)
+ assert(node == sameByPath)
+ assert(node.hashCode == sameByPath.hashCode)
+ assert(node != new PhysicalFileNode(repo, other, 1L))
+ }
+
+ "PhysicalFileNode.getAllFileRelativePaths" should "collect files recursively through directories" in withRepo {
+ repo =>
+ val sub = Files.createDirectory(repo.resolve("sub"))
+ val topFile = Files.createFile(repo.resolve("top.txt"))
+ val innerFile = Files.createFile(sub.resolve("inner.txt"))
+ val topNode = new PhysicalFileNode(repo, topFile, 0L)
+ val dirNode = new PhysicalFileNode(repo, sub, 0L)
+ dirNode.addChildNode(new PhysicalFileNode(repo, innerFile, 0L))
+ val nodes = new java.util.HashSet[PhysicalFileNode]()
+ nodes.add(topNode)
+ nodes.add(dirNode)
+ val result = PhysicalFileNode.getAllFileRelativePaths(nodes).asScala.toSet
+ assert(
+ result == Set(repo.relativize(topFile).toString, repo.relativize(innerFile).toString)
+ )
+ }
+}
diff --git a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/tuple/SchemaSpec.scala b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/tuple/SchemaSpec.scala
index 1e2d22d6a3..cf62f7ba15 100644
--- a/common/workflow-core/src/test/scala/org/apache/texera/amber/core/tuple/SchemaSpec.scala
+++ b/common/workflow-core/src/test/scala/org/apache/texera/amber/core/tuple/SchemaSpec.scala
@@ -19,6 +19,7 @@
package org.apache.texera.amber.core.tuple
+import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.scalatest.flatspec.AnyFlatSpec
class SchemaSpec extends AnyFlatSpec {
@@ -274,4 +275,51 @@ class SchemaSpec extends AnyFlatSpec {
schema.toString == "Schema[Attribute[name=id, type=integer], Attribute[name=name, type=string]]"
)
}
+
+ it should "produce equal, deterministic hash codes for equal schemas" in {
+ val a = Schema(
+ List(new Attribute("id", AttributeType.INTEGER), new Attribute("name", AttributeType.STRING))
+ )
+ val b = Schema(
+ List(new Attribute("id", AttributeType.INTEGER), new Attribute("name", AttributeType.STRING))
+ )
+ assert(a.hashCode == a.hashCode)
+ assert(a == b && a.hashCode == b.hashCode)
+ assert(Schema().hashCode == Schema().hashCode)
+ }
+
+ it should "not be equal to a non-Schema object" in {
+ val schema = Schema(List(new Attribute("id", AttributeType.INTEGER)))
+ assert(!schema.equals("not a schema"))
+ assert(!schema.equals(42))
+ assert(!schema.equals(null))
+ }
+
+ it should "throw when add(Iterable) contains a name already in the schema" in {
+ val schema = Schema(List(new Attribute("id", AttributeType.INTEGER)))
+ val ex = intercept[RuntimeException] {
+ schema.add(List(new Attribute("id", AttributeType.STRING)))
+ }
+ assert(ex.getMessage == "Cannot add attributes with duplicate names: id")
+ }
+
+ it should "throw when add(Attribute) name already exists" in {
+ val schema = Schema(List(new Attribute("id", AttributeType.INTEGER)))
+ val ex = intercept[RuntimeException] {
+ schema.add(new Attribute("id", AttributeType.LONG))
+ }
+ assert(ex.getMessage == "Attribute name 'id' already exists in the schema")
+ }
+
+ it should "round-trip through Jackson via the @JsonCreator constructor" in {
+ val schema =
+ Schema(
+ List(
+ new Attribute("id", AttributeType.INTEGER),
+ new Attribute("name", AttributeType.STRING)
+ )
+ )
+ val restored = objectMapper.readValue(objectMapper.writeValueAsString(schema), classOf[Schema])
+ assert(restored == schema)
+ }
}